Day 3: Subqueries & derived tables
On this page
By the end of this lesson you can
- Write scalar subqueries that return one value and explain error 512 when a "scalar" subquery returns several rows.
- Filter with
IN (subquery)and explain whyNOT IN (subquery)returns no rows when the subquery produces a NULL. - Write correlated subqueries that refer to the outer query, and trace how they are evaluated for each outer row.
- Use
EXISTSandNOT EXISTSfor "has at least one…" and "has none…" questions. - Build derived tables to aggregate in two stages (for example, order totals first, then the average order value).
- Use
LEFT JOIN … WHERE right.key IS NULLto find unmatched rows, and compare it withNOT EXISTS. - Choose between a subquery, a derived table and a join for a given question.
A. Lesson overview
| Week | 1 — Advanced querying foundations |
| Level | Intermediate |
| Estimated time | 2 h 30 min essential + 30 min optional stretch |
| Prerequisites | Days 1–2: filtering, CASE, NULL logic, aggregation, GROUP BY/HAVING, INNER JOIN |
| Syllabus topic | Subqueries & Derived Tables — correlated subqueries, EXISTS, IN, derived tables |
| Foundations added today | LEFT JOIN primer: keeping rows that have no match |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Self-contained subqueries: scalar and multi-valued | 20 min |
Part 2 — IN, NOT IN and the NULL trap |
15 min |
Part 3 — Correlated subqueries and EXISTS |
25 min |
| Part 4 — Derived tables | 15 min |
Part 5 — Primer: LEFT JOIN |
15 min |
| Worked examples | 20 min |
| Guided lab | 20 min |
| Independent practice | 15 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. The customer team is preparing a re-engagement campaign. They want to know which customers have never ordered, which have gone quiet, which bought premium products, and which placed unusually large orders compared with their own history. Each question compares a row with something computed from other rows — the natural job of subqueries.
B. Theory explained clearly
Part 1 — Self-contained subqueries
B1. What a subquery is
A subquery is a SELECT statement nested inside another statement, enclosed in parentheses. The outer statement uses its result as a value, a list of values or a table.
| Kind | Returns | Typical place |
|---|---|---|
| Scalar | Exactly one value (one row, one column) | Anywhere a single value can go: SELECT, WHERE, HAVING |
| Multi-valued | One column, any number of rows | IN (…), NOT IN (…) |
| Table-valued | Rows and columns | FROM (…) AS alias — a derived table |
A subquery is self-contained when it does not refer to the outer query: you could highlight it and run it on its own. It is correlated when it refers to a column of the outer query (Part 3).
B2. Scalar subqueries
USE RetailLab;
GO
-- Which products cost more than the average list price?
SELECT ProductCode, ProductName, ListPrice
FROM Inventory.Product
WHERE ListPrice > (SELECT AVG(ListPrice) FROM Inventory.Product)
ORDER BY ListPrice DESC, ProductCode;
The inner query runs once and returns 146.749000; the outer query compares every product with it. You could not write WHERE ListPrice > AVG(ListPrice) — an aggregate cannot appear in WHERE (Day 2).
If a scalar subquery returns more than one row, SQL Server raises error 512: Subquery returned more than 1 value. This is not permitted when the subquery follows =, !=, <, <=, >, >= or when the subquery is used as an expression. If it returns no rows, the value is NULL — and a comparison with NULL is UNKNOWN, so the outer row is filtered out silently.
A scalar subquery can also appear in the SELECT list:
SELECT ProductCode,
ListPrice,
ListPrice - (SELECT AVG(ListPrice) FROM Inventory.Product) AS DiffFromAverage
FROM Inventory.Product
ORDER BY ProductCode;
B3. ANY, SOME and ALL (for recognition)
> ALL (subquery) is TRUE when the value is greater than every value returned; > ANY (subquery) (or SOME) when it is greater than at least one. They are rarely needed — > (SELECT MAX(…)) and > (SELECT MIN(…)) say the same thing more clearly — but you will meet them in existing code. Watch out: > ALL over an empty subquery is TRUE.
Part 2 — IN, NOT IN and the NULL trap
B4. IN with a subquery
-- Customers who have bought the Handheld GPS Unit (product 22)
SELECT CustomerID, FirstName, LastName
FROM Sales.Customer
WHERE CustomerID IN (SELECT o.CustomerID
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE l.ProductID = 22)
ORDER BY CustomerID;
The subquery returns a list of customer IDs (with duplicates — that does not matter to IN). Each customer appears once in the result, even if they bought the GPS several times. Compare that with joining Sales.Customer directly to the order lines, which would repeat a customer once per matching line: IN filters, a join multiplies.
B5. NOT IN and NULLs
x NOT IN (a, b, c) means x <> a AND x <> b AND x <> c. If any list value is NULL, one of those comparisons is UNKNOWN, so the whole condition can never be TRUE. With a literal list you can see the NULL; with a subquery you usually cannot.
A real example: “Which employees manage nobody?”
-- Returns 0 rows!
SELECT EmployeeID, FirstName, LastName
FROM HR.Employee
WHERE EmployeeID NOT IN (SELECT ManagerID FROM HR.Employee);
Eleanor Hughes, the Managing Director, has ManagerID = NULL, so the subquery’s list contains a NULL and every row is filtered out. Two fixes:
-- Fix 1: exclude NULLs from the list
WHERE EmployeeID NOT IN (SELECT ManagerID FROM HR.Employee WHERE ManagerID IS NOT NULL)
-- Fix 2 (preferred): NOT EXISTS, which is not affected by NULLs (Part 3)
WHERE NOT EXISTS (SELECT 1 FROM HR.Employee AS r WHERE r.ManagerID = e.EmployeeID)
Rule of thumb: use NOT EXISTS for “has no matching rows”. Use NOT IN only with literal lists or with a subquery column that cannot be NULL.
Part 3 — Correlated subqueries and EXISTS
B6. Correlated subqueries
A correlated subquery refers to a column of the outer query, so its result can be different for every outer row.
-- Products priced above the average of their OWN category
SELECT p.ProductCode, p.ProductName, p.CategoryID, p.ListPrice
FROM Inventory.Product AS p
WHERE p.ListPrice > (SELECT AVG(x.ListPrice)
FROM Inventory.Product AS x
WHERE x.CategoryID = p.CategoryID) -- p comes from the outer query
ORDER BY p.CategoryID, p.ProductCode;
How to read it. For each product p, the inner query computes the average price of products in p’s category, then the outer WHERE compares. Logically the subquery runs once per outer row. SQL Server’s optimiser is free to execute it more cleverly (often as a join with a pre-computed aggregate), so a correlated subquery is not automatically slow — but it helps to understand the logical model.
Aliases matter. The same table appears twice, so give each occurrence its own alias (p outer, x inner) and qualify every column. If you write WHERE CategoryID = CategoryID, SQL Server resolves both names to the inner table and the condition is always true — no error, wrong answer.
B7. EXISTS and NOT EXISTS
EXISTS (subquery) is TRUE if the subquery returns at least one row, FALSE otherwise. It is never UNKNOWN, so NULLs cannot trip it up.
-- Customers who ordered in December 2025
SELECT c.CustomerID, c.FirstName, c.LastName
FROM Sales.Customer AS c
WHERE EXISTS (SELECT 1
FROM Sales.SalesOrder AS o
WHERE o.CustomerID = c.CustomerID
AND o.OrderDate >= '2025-12-01')
ORDER BY c.CustomerID;
- The select list inside
EXISTSis ignored;SELECT 1is a common convention that signals “I only care whether a row exists”. EXISTScan stop as soon as it finds one matching row.NOT EXISTSanswers “has no matching rows” correctly even when columns contain NULLs.
B8. Correlated scalar subqueries in the SELECT list
SELECT c.CustomerID,
c.FirstName,
c.LastName,
(SELECT MAX(o.OrderDate)
FROM Sales.SalesOrder AS o
WHERE o.CustomerID = c.CustomerID) AS LastOrderDate
FROM Sales.Customer AS c
ORDER BY c.CustomerID;
Customers with no orders get NULL, because MAX over no rows is NULL. This style is easy to read for one or two values. When you need several values from the same related rows (count, last date and total), a derived table that computes them all at once is usually clearer — Part 4.
Part 4 — Derived tables
B9. What a derived table is
A derived table is a subquery in the FROM clause. Its result is treated as a table for the outer query.
SELECT t.SalesChannel,
COUNT(*) AS Orders,
CAST(AVG(t.OrderNet) AS decimal(10,2)) AS AvgOrderValue
FROM (SELECT o.OrderID,
o.SalesChannel,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.OrderID, o.SalesChannel) AS t -- one row per ORDER
GROUP BY t.SalesChannel
ORDER BY t.SalesChannel;
This is the clean way to aggregate an aggregate. Day 2 computed average order value as SUM ÷ COUNT(DISTINCT OrderID). A derived table makes the grain explicit: the inner query returns one row per order, so the outer AVG averages orders — and you can also take MIN and MAX of order values, which the Day 2 formula cannot.
Rules for derived tables
| Rule | Example of the error if broken |
|---|---|
| It must have an alias | Incorrect syntax near ‘)’ |
| Every column must have a name (alias computed columns) | No column name was specified for column 3 of ‘t’ |
| Column names must be unique | The column ‘OrderID’ was specified multiple times for ‘t’ |
ORDER BY is not allowed inside it (unless with TOP/OFFSET) |
The ORDER BY clause is invalid in views, inline functions, derived tables… |
A derived table also lets you name a calculation once and filter on it — the limitation from Day 1, where a SELECT alias could not be used in WHERE:
SELECT t.ProductCode, t.UnitMargin
FROM (SELECT ProductCode, ListPrice - StandardCost AS UnitMargin
FROM Inventory.Product) AS t
WHERE t.UnitMargin > 100 -- the alias is now a real column of t
ORDER BY t.UnitMargin DESC, t.ProductCode;
B10. Table value constructors
A VALUES list in parentheses with an alias and column names is a derived table built from literals. You met it on Day 2:
SELECT v.Segment, v.DiscountPct
FROM (VALUES ('Consumer', 0.00), ('Business', 0.05), ('VIP', 0.10)) AS v (Segment, DiscountPct)
ORDER BY v.DiscountPct;
It is handy for small lookup lists inside a query.
Part 5 — Primer: LEFT JOIN
B11. Keeping rows that have no match
INNER JOIN keeps only rows that match. A LEFT JOIN (in full, LEFT OUTER JOIN) keeps every row from the left table; where there is no matching right-hand row, the right-hand columns are NULL.
SELECT c.CustomerID, c.FirstName, c.LastName, o.OrderID, o.OrderDate
FROM Sales.Customer AS c
LEFT JOIN Sales.SalesOrder AS o ON o.CustomerID = c.CustomerID
ORDER BY c.CustomerID, o.OrderID;
Result: 381 rows — the 376 orders paired with their customers, plus one row each for the 5 customers who have never ordered, with NULL in OrderID and OrderDate.
B12. The anti-join pattern
Adding WHERE o.OrderID IS NULL keeps only the unmatched rows:
SELECT c.CustomerID, c.FirstName, c.LastName
FROM Sales.Customer AS c
LEFT JOIN Sales.SalesOrder AS o ON o.CustomerID = c.CustomerID
WHERE o.OrderID IS NULL -- test a column that is never NULL in a real match
ORDER BY c.CustomerID;
Two cautions, both covered fully on Day 8:
- Test a right-hand column that cannot be NULL in a matched row — normally its primary key. Testing a nullable column such as
o.StoreIDwould also return customers whose only orders were online. - Conditions on the right-hand table belong in
ON, notWHERE, if you want to keep unmatched left rows.WHERE o.OrderDate >= '2025-12-01'after aLEFT JOINremoves every customer with no December order — including the NULL rows you wanted to keep — so the join silently behaves like an inner join.
For “has no orders”, NOT EXISTS and the LEFT JOIN … IS NULL pattern return the same rows. Many developers find NOT EXISTS states the intent more directly; SQL Server often produces the same execution plan for both.
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor), core data only |
| Database | RetailLab |
| Tables used | Sales.Customer, Sales.SalesOrder, Sales.SalesOrderLine, Inventory.Product, HR.Employee |
| Relationships used | SalesOrder.CustomerID → Customer; SalesOrderLine.OrderID → SalesOrder; SalesOrderLine.ProductID → Product; Employee.ManagerID → Employee |
| Permissions | SELECT on these tables |
| Objects created today | None — Day 3 is read-only |
Preparation. None if RetailLab is unchanged since setup. Otherwise run scripts/setup/04_reset_training_data.sql in SQLCMD Mode (destructive: drops and rebuilds RetailLab) or scripts 00 → 03.
Validation. Expected: 40, 35, 5, 1.
USE RetailLab;
GO
SELECT (SELECT COUNT(*) FROM Sales.Customer) AS Customers,
(SELECT COUNT(DISTINCT CustomerID) FROM Sales.SalesOrder) AS CustomersWhoOrdered,
(SELECT COUNT(*) FROM Sales.Customer AS c
WHERE NOT EXISTS (SELECT 1 FROM Sales.SalesOrder AS o
WHERE o.CustomerID = c.CustomerID)) AS CustomersNeverOrdered,
(SELECT COUNT(*) FROM HR.Employee WHERE ManagerID IS NULL) AS EmployeesWithNoManager;
D. Worked examples
Worked example 1 — Above-average products (scalar subquery)
SELECT ProductCode, ProductName, ListPrice
FROM Inventory.Product
WHERE ListPrice > (SELECT AVG(ListPrice) FROM Inventory.Product)
ORDER BY ListPrice DESC, ProductCode;
Expected result: 12 rows, from TNT-004 (599.00) to JKT-004 (159.00). The average is 146.749; the next product down, FTW-001 at 139.00, is below it.
Worked example 2 — The NOT IN trap, live
-- A: returns 0 rows because the subquery's list contains a NULL
SELECT EmployeeID, FirstName, LastName
FROM HR.Employee
WHERE EmployeeID NOT IN (SELECT ManagerID FROM HR.Employee);
-- B: correct
SELECT e.EmployeeID, e.FirstName, e.LastName, e.JobTitle
FROM HR.Employee AS e
WHERE NOT EXISTS (SELECT 1
FROM HR.Employee AS r -- r = a possible direct report
WHERE r.ManagerID = e.EmployeeID)
ORDER BY e.EmployeeID;
Expected result: A returns 0 rows. B returns 10 rows: employees 5 (Aisha Khan, IT Manager — a manager by title, but with no reports in the data), 7, 8, 9, 10, 12, 13, 14, 15 and 16.
Business meaning: the job title and the reporting line disagree for Aisha Khan. Data like this is worth raising with HR rather than “fixing” in a query.
Worked example 3 — Products above their category average (correlated subquery)
SELECT p.ProductCode,
p.ProductName,
p.CategoryID,
p.ListPrice,
(SELECT CAST(AVG(x.ListPrice) AS decimal(10,2))
FROM Inventory.Product AS x
WHERE x.CategoryID = p.CategoryID) AS CategoryAvgPrice
FROM Inventory.Product AS p
WHERE p.ListPrice > (SELECT AVG(x.ListPrice)
FROM Inventory.Product AS x
WHERE x.CategoryID = p.CategoryID)
ORDER BY p.CategoryID, p.ProductCode;
Expected result: 15 rows. First three: TNT-002 (349.00 vs 346.50), TNT-004 (599.00 vs 346.50), SLP-002 (299.00 vs 162.33). Category 14 contributes no rows: both waterproof jackets cost 189.00, so neither is above the average.
What to notice: the same correlated subquery appears twice. That repetition is a sign a derived table or (tomorrow) a CTE would be cleaner. You will rewrite this query on Day 4.
Worked example 4 — Recent buyers (EXISTS)
SELECT c.CustomerID, c.FirstName, c.LastName
FROM Sales.Customer AS c
WHERE EXISTS (SELECT 1
FROM Sales.SalesOrder AS o
WHERE o.CustomerID = c.CustomerID
AND o.OrderDate >= '2025-12-01'
AND o.OrderDate < '2026-01-01')
ORDER BY c.CustomerID;
Expected result: 20 customers, starting with 1 (Alice Morgan), 2, 4, 6, 8 and ending with 33, 34, 35. Each customer appears once, however many December orders they placed.
Worked example 5 — Who bought the GPS? (IN)
SELECT CustomerID, FirstName, LastName
FROM Sales.Customer
WHERE CustomerID IN (SELECT o.CustomerID
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE l.ProductID = 22)
ORDER BY CustomerID;
Expected result: 15 customers: 1, 4, 6, 9, 12, 13, 17, 18, 20, 22, 23, 24, 26, 27 and 29. Rewriting with EXISTS and a correlated subquery gives the same 15 rows.
Worked example 6 — Last order date per customer (correlated scalar subquery)
SELECT c.CustomerID,
c.FirstName,
c.LastName,
(SELECT MAX(o.OrderDate)
FROM Sales.SalesOrder AS o
WHERE o.CustomerID = c.CustomerID) AS LastOrderDate
FROM Sales.Customer AS c
WHERE c.CustomerID IN (1, 5, 14, 36, 37)
ORDER BY c.CustomerID;
Expected result
| CustomerID | FirstName | LastName | LastOrderDate |
|---|---|---|---|
| 1 | Alice | Morgan | 2025-12-29 |
| 5 | Emma | Wilson | 2025-10-10 |
| 14 | Niamh | Kelly | 2024-06-25 |
| 36 | Mia | Harper | NULL |
| 37 | Leo | Fitzgerald | NULL |
Worked example 7 — Order value statistics by channel (derived table)
SELECT t.SalesChannel,
COUNT(*) AS Orders,
CAST(AVG(t.OrderNet) AS decimal(10,2)) AS AvgOrderValue,
CAST(MIN(t.OrderNet) AS decimal(10,2)) AS SmallestOrder,
CAST(MAX(t.OrderNet) AS decimal(10,2)) AS LargestOrder
FROM (SELECT o.OrderID,
o.SalesChannel,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.OrderID, o.SalesChannel) AS t
GROUP BY t.SalesChannel
ORDER BY t.SalesChannel;
Expected result
| SalesChannel | Orders | AvgOrderValue | SmallestOrder | LargestOrder |
|---|---|---|---|---|
| Online | 211 | 327.38 | 14.24 | 1577.70 |
| Store | 158 | 337.20 | 11.70 | 1523.00 |
Why the inner GROUP BY includes SalesChannel: the outer query needs it, and the golden rule from Day 2 says a non-aggregated column must be grouped. Because each order has exactly one channel, adding it does not change the grain.
Worked example 8 — Customers who have never ordered (LEFT JOIN vs NOT EXISTS)
-- Anti-join with LEFT JOIN
SELECT c.CustomerID, c.FirstName, c.LastName, c.DateJoined
FROM Sales.Customer AS c
LEFT JOIN Sales.SalesOrder AS o ON o.CustomerID = c.CustomerID
WHERE o.OrderID IS NULL
ORDER BY c.CustomerID;
-- The same question with NOT EXISTS
SELECT c.CustomerID, c.FirstName, c.LastName, c.DateJoined
FROM Sales.Customer AS c
WHERE NOT EXISTS (SELECT 1 FROM Sales.SalesOrder AS o WHERE o.CustomerID = c.CustomerID)
ORDER BY c.CustomerID;
Expected result (both): 5 rows — 36 Mia Harper (joined 2024-03-12, now inactive), 37 Leo Fitzgerald, 38 Freya Lindqvist, 39 Arjun Mehta and 40 Hugo Bernard (all joined November–December 2025).
Business meaning: four of the five joined in the last two months — they are new, not lost. A useful campaign needs that context, which the guided lab adds.
E. Guided practical lab — Customer health check
The customer team wants every active customer classified as Active, Lapsed (no order since 1 September 2025) or Never ordered, with their order count, last order date and lifetime net sales. Cancelled orders do not count.
Step 1 — Summarise orders per customer in a derived table
- Do: Write the per-customer summary on its own first.
- Run:
USE RetailLab; GO SELECT o.CustomerID, COUNT(DISTINCT o.OrderID) AS Orders, MAX(o.OrderDate) AS LastOrderDate, SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS LifetimeNet FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderStatus <> 'Cancelled' GROUP BY o.CustomerID ORDER BY o.CustomerID; - Observe: 35 rows — one per customer who has ordered.
- Why it matters: Testing the inner query on its own is the quickest way to debug a derived table.
- Confirm: 35 rows; customer 1 shows 27 orders, last order 2025-12-29.
Step 2 — Bring in every customer with a LEFT JOIN
- Do: Remove the
ORDER BY(not allowed inside a derived table), wrap the query in parentheses, alias its, andLEFT JOINit toSales.Customer. - Run:
SELECT c.CustomerID, CONCAT_WS(N' ', c.FirstName, c.LastName) AS CustomerName, s.Orders, s.LastOrderDate, CAST(s.LifetimeNet AS decimal(12,2)) AS LifetimeNet FROM Sales.Customer AS c LEFT JOIN (SELECT o.CustomerID, COUNT(DISTINCT o.OrderID) AS Orders, MAX(o.OrderDate) AS LastOrderDate, SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS LifetimeNet FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderStatus <> 'Cancelled' GROUP BY o.CustomerID) AS s ON s.CustomerID = c.CustomerID WHERE c.IsActive = 1 ORDER BY c.CustomerID; - Observe: 37 rows. Customers 37–40 show NULL in every summary column.
- Why it matters: The
LEFT JOINkeeps customers with no orders. TheWHERE c.IsActive = 1filter is on the left table, so it is safe inWHERE. - Confirm: 37 rows (40 customers minus 3 inactive).
Step 3 — Classify each customer
- Do: Add a
CASEcolumn. Test “never ordered” first, because for those customersLastOrderDateis NULL. - Run: add this column to the
SELECTlist of step 2:, CASE WHEN s.CustomerID IS NULL THEN 'Never ordered' WHEN s.LastOrderDate < '2025-09-01' THEN 'Lapsed' ELSE 'Active' END AS HealthStatus - Observe: customers 21, 22 and 31 are Lapsed; 37–40 Never ordered; the other 30 Active.
- Why it matters: If you tested
s.LastOrderDate < '2025-09-01'without the first branch, a NULL date would be UNKNOWN and fall toELSE— never-ordered customers would be labelled Active. - Confirm: exactly 3 Lapsed and 4 Never ordered.
Step 4 — Show only the customers to re-engage
-
Do: You cannot filter on the alias
HealthStatusinWHERE. Wrap the whole step-3 query in a second derived table and filter that. -
Run:
SELECT h.* FROM (SELECT c.CustomerID, CONCAT_WS(N' ', c.FirstName, c.LastName) AS CustomerName, s.Orders, s.LastOrderDate, CAST(s.LifetimeNet AS decimal(12,2)) AS LifetimeNet, CASE WHEN s.CustomerID IS NULL THEN 'Never ordered' WHEN s.LastOrderDate < '2025-09-01' THEN 'Lapsed' ELSE 'Active' END AS HealthStatus FROM Sales.Customer AS c LEFT JOIN (SELECT o.CustomerID, COUNT(DISTINCT o.OrderID) AS Orders, MAX(o.OrderDate) AS LastOrderDate, SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS LifetimeNet FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderStatus <> 'Cancelled' GROUP BY o.CustomerID) AS s ON s.CustomerID = c.CustomerID WHERE c.IsActive = 1) AS h WHERE h.HealthStatus <> 'Active' ORDER BY h.HealthStatus, h.CustomerID; -
Observe: 7 rows.
CustomerID CustomerName Orders LastOrderDate LifetimeNet HealthStatus 21 Victor Dubois 6 2025-07-25 1806.98 Lapsed 22 Willow Green 3 2025-07-22 958.00 Lapsed 31 Noah Baker 5 2025-02-16 1608.70 Lapsed 37 Leo Fitzgerald NULL NULL NULL Never ordered 38 Freya Lindqvist NULL NULL NULL Never ordered 39 Arjun Mehta NULL NULL NULL Never ordered 40 Hugo Bernard NULL NULL NULL Never ordered -
Why it matters: Nesting derived tables lets you build a query in layers, each of which you can test. It also becomes hard to read — tomorrow’s CTEs express the same layers top to bottom.
-
Confirm: 7 rows in the order shown.
Step 5 — Cross-check the “never ordered” group with NOT EXISTS
- Do: Independently count active customers with no non-cancelled orders.
- Run:
SELECT COUNT(*) AS ActiveNeverOrdered FROM Sales.Customer AS c WHERE c.IsActive = 1 AND NOT EXISTS (SELECT 1 FROM Sales.SalesOrder AS o WHERE o.CustomerID = c.CustomerID AND o.OrderStatus <> 'Cancelled'); - Observe:
4. - Why it matters: Checking a result with a second, independent method is one of the most effective habits in data work.
- Confirm: 4 matches the Never ordered count in step 4.
F. Independent practice
Use ORDER BY with a unique tie-breaker in every query.
Task 1 — Products never sold (straightforward)
Return ProductID, ProductCode, ProductName and LaunchDate for products that appear on no order line. Write it once with NOT EXISTS and once with NOT IN.
Success criteria: 1 row: product 29, NAV-004 Satellite Messenger, launched 2025-12-01. Both versions agree.
Hint
Is Sales.SalesOrderLine.ProductID nullable? That decides whether NOT IN is safe here.
Task 2 — Paid above the department average (correlated subquery)
Return EmployeeID, FirstName, LastName, DepartmentID, Salary and DeptAvgSalary (2 decimal places) for employees whose salary is above their own department’s average. Include leavers. Sort by DepartmentID, then salary (highest first), then EmployeeID.
Success criteria: 4 rows — Raj Patel and Liam O’Connor (Sales, average 41,250.00), Fiona Campbell (Warehouse & Logistics, 42,250.00) and Tom Baxter (Finance, 50,500.00).
Hint
Departments with one employee (Executive, IT) cannot have anyone above their average.
Task 3 — Unusually large 2025 orders (derived table + correlation)
Customer Services wants 2025 orders whose net value is more than double that customer’s own average order value. Use all of the customer’s non-cancelled orders (2024 and 2025) to compute the average, and exclude cancelled orders from the result.
Return OrderID, CustomerID, OrderDate, OrderNet and CustomerAvgOrder (both 2 decimal places), sorted by OrderDate, then OrderID.
Success criteria: 19 rows, starting with order 1177 (customer 13, 648.85 vs 319.10) and ending with order 1376 (customer 17, 707.00 vs 264.71). Customer 13 appears three times.
Hints
- Build a derived table
twith one row per non-cancelled order:OrderID,CustomerID,OrderDate,OrderNet. - In the outer query, compare
t.OrderNetwith a correlated subquery that averagesOrderNetover the rows of a second copy of the same derived table for the same customer — or, more simply, jointto a second derived table of per-customer averages.
Task 4 — Each customer’s largest order (challenging)
For every customer who has ordered, return the single largest non-cancelled order: CustomerID, OrderID, OrderDate and OrderNet (2 decimal places). Sort by OrderNet highest first, then CustomerID.
Success criteria: 35 rows (no customer has two orders tied for largest). The first is customer 26, order 1014 (2024-02-07, 1577.70); the last is customer 7, order 1142 (248.00).
Hint
Build the per-order derived table, then keep a row only when its OrderNet equals the maximum OrderNet for the same customer. Think about what your query would do if two orders tied, and say so in a comment.
Stretch challenge — Bought the whole tent range (relational division)
Which customers have bought every currently available tent — every product in category 2 (Tents) with no DiscontinuedDate — in non-cancelled orders? Return CustomerID, FirstName and LastName.
Success criteria: 5 customers: 1, 13, 18, 27 and 29. Adding a new tent to the catalogue would make the answer 0 rows without changing your query.
Hint
Rephrase with a double negative: customers for whom there is no available tent that they have not bought → NOT EXISTS ( … available tents … AND NOT EXISTS ( … this customer bought this tent … ) ).
G. Solutions and explanations
Show solution 1
-- NOT EXISTS
SELECT p.ProductID, p.ProductCode, p.ProductName, p.LaunchDate
FROM Inventory.Product AS p
WHERE NOT EXISTS (SELECT 1 FROM Sales.SalesOrderLine AS l WHERE l.ProductID = p.ProductID)
ORDER BY p.ProductID;
-- NOT IN — safe ONLY because SalesOrderLine.ProductID is declared NOT NULL
SELECT ProductID, ProductCode, ProductName, LaunchDate
FROM Inventory.Product
WHERE ProductID NOT IN (SELECT ProductID FROM Sales.SalesOrderLine)
ORDER BY ProductID;
Result: product 29, NAV-004, Satellite Messenger, 2025-12-01. It launched a month before the reference date, so “never sold” is not yet alarming. The NOT IN version is safe today, but if the column ever became nullable it would silently return nothing; NOT EXISTS has no such risk. A LEFT JOIN … WHERE l.OrderLineID IS NULL is also correct.
Show solution 2
SELECT e.EmployeeID,
e.FirstName,
e.LastName,
e.DepartmentID,
e.Salary,
(SELECT CAST(AVG(x.Salary) AS decimal(10,2))
FROM HR.Employee AS x
WHERE x.DepartmentID = e.DepartmentID) AS DeptAvgSalary
FROM HR.Employee AS e
WHERE e.Salary > (SELECT AVG(x.Salary)
FROM HR.Employee AS x
WHERE x.DepartmentID = e.DepartmentID)
ORDER BY e.DepartmentID, e.Salary DESC, e.EmployeeID;
| EmployeeID | FirstName | LastName | DepartmentID | Salary | DeptAvgSalary |
|---|---|---|---|---|---|
| 2 | Raj | Patel | 2 | 85000.00 | 41250.00 |
| 6 | Liam | O’Connor | 2 | 52000.00 | 41250.00 |
| 3 | Fiona | Campbell | 3 | 78000.00 | 42250.00 |
| 4 | Tom | Baxter | 4 | 72000.00 | 50500.00 |
Reasoning. The averages include each department’s manager, which pulls them up; that is a business choice worth stating. Alternative: a derived table of DepartmentID, AVG(Salary) joined to HR.Employee — useful when you want the average in the output without computing it twice.
Show solution 3
SELECT t.OrderID,
t.CustomerID,
t.OrderDate,
CAST(t.OrderNet AS decimal(10,2)) AS OrderNet,
CAST(a.AvgOrder AS decimal(10,2)) AS CustomerAvgOrder
FROM (SELECT o.OrderID, o.CustomerID, o.OrderDate,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.OrderID, o.CustomerID, o.OrderDate) AS t
JOIN (SELECT x.CustomerID, AVG(x.OrderNet) AS AvgOrder
FROM (SELECT o.OrderID, o.CustomerID,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.OrderID, o.CustomerID) AS x
GROUP BY x.CustomerID) AS a
ON a.CustomerID = t.CustomerID
WHERE t.OrderDate >= '2025-01-01'
AND t.OrderDate < '2026-01-01'
AND t.OrderNet > 2 * a.AvgOrder
ORDER BY t.OrderDate, t.OrderID;
Result: 19 rows.
| OrderID | CustomerID | OrderDate | OrderNet | CustomerAvgOrder |
|---|---|---|---|---|
| 1177 | 13 | 2025-01-06 | 648.85 | 319.10 |
| 1201 | 34 | 2025-03-03 | 1004.98 | 367.00 |
| 1216 | 23 | 2025-03-23 | 783.75 | 315.05 |
| 1217 | 29 | 2025-03-26 | 927.20 | 395.20 |
| 1218 | 30 | 2025-04-01 | 986.00 | 357.00 |
| 1227 | 24 | 2025-04-12 | 1523.00 | 521.22 |
| 1241 | 33 | 2025-05-15 | 613.70 | 174.90 |
| 1254 | 18 | 2025-06-03 | 815.40 | 329.02 |
| 1257 | 8 | 2025-06-10 | 1047.00 | 425.84 |
| 1282 | 12 | 2025-07-16 | 1116.90 | 326.30 |
| 1305 | 10 | 2025-09-06 | 928.00 | 266.65 |
| 1314 | 5 | 2025-09-25 | 387.00 | 165.60 |
| 1325 | 16 | 2025-10-12 | 926.98 | 346.99 |
| 1339 | 13 | 2025-11-10 | 889.20 | 319.10 |
| 1345 | 12 | 2025-11-23 | 734.40 | 326.30 |
| 1356 | 23 | 2025-12-13 | 1153.30 | 315.05 |
| 1370 | 20 | 2025-12-26 | 897.00 | 430.89 |
| 1371 | 13 | 2025-12-27 | 662.15 | 319.10 |
| 1376 | 17 | 2025-12-31 | 707.00 | 264.71 |
Reasoning. The date filter is applied in the outer query, so the average still uses both years. Applying it inside the derived tables would change the question to “more than double the customer’s 2025 average”. The comparison uses unrounded values; rounding is for display only.
The obvious drawback is that the per-order derived table is written twice. Day 4’s CTEs let you define it once and use it twice — you will rewrite this query tomorrow. A correlated subquery against a second copy of t is an acceptable alternative.
Show solution 4
SELECT t.CustomerID,
t.OrderID,
t.OrderDate,
CAST(t.OrderNet AS decimal(10,2)) AS OrderNet
FROM (SELECT o.OrderID, o.CustomerID, o.OrderDate,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.OrderID, o.CustomerID, o.OrderDate) AS t
WHERE t.OrderNet = (SELECT MAX(y.OrderNet)
FROM (SELECT o2.CustomerID,
SUM(l2.Quantity * l2.UnitPrice * (1 - l2.DiscountPct)) AS OrderNet
FROM Sales.SalesOrder AS o2
JOIN Sales.SalesOrderLine AS l2 ON l2.OrderID = o2.OrderID
WHERE o2.OrderStatus <> 'Cancelled'
GROUP BY o2.OrderID, o2.CustomerID) AS y
WHERE y.CustomerID = t.CustomerID)
-- If two orders tied for a customer's maximum, BOTH would be returned.
ORDER BY t.OrderNet DESC, t.CustomerID;
Result: 35 rows. The first five:
| CustomerID | OrderID | OrderDate | OrderNet |
|---|---|---|---|
| 26 | 1014 | 2024-02-07 | 1577.70 |
| 27 | 1312 | 2025-09-19 | 1574.00 |
| 24 | 1227 | 2025-04-12 | 1523.00 |
| 12 | 1157 | 2024-12-06 | 1440.00 |
| 23 | 1356 | 2025-12-13 | 1153.30 |
The last row is customer 7, order 1142, 2024-11-16, 248.00.
Reasoning. This is the classic “row holding the group’s maximum” problem. The equality approach returns all tied rows, which may or may not be what the business wants. On Day 5 you will meet ROW_NUMBER(), which picks exactly one row per group with an explicit tie-breaker and avoids writing the derived table twice.
Show the stretch solution
SELECT c.CustomerID, c.FirstName, c.LastName
FROM Sales.Customer AS c
WHERE NOT EXISTS ( -- there is no available tent…
SELECT 1
FROM Inventory.Product AS p
WHERE p.CategoryID = 2
AND p.DiscontinuedDate IS NULL
AND NOT EXISTS ( -- …that this customer has not bought
SELECT 1
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.CustomerID = c.CustomerID
AND l.ProductID = p.ProductID
AND o.OrderStatus <> 'Cancelled'))
ORDER BY c.CustomerID;
Result: 1 Alice Morgan, 13 Mohammed Rahman, 18 Sofia Rossi, 27 Søren Jensen, 29 Henry Shaw.
Reasoning. This pattern is called relational division. An alternative counts distinct tents bought per customer and compares with the number of available tents in HAVING:
SELECT o.CustomerID
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
WHERE p.CategoryID = 2 AND p.DiscontinuedDate IS NULL AND o.OrderStatus <> 'Cancelled'
GROUP BY o.CustomerID
HAVING COUNT(DISTINCT l.ProductID) = (SELECT COUNT(*) FROM Inventory.Product
WHERE CategoryID = 2 AND DiscontinuedDate IS NULL)
ORDER BY o.CustomerID;
Be careful with an edge case: if there were no available tents, the double NOT EXISTS version would return every customer (all of them “bought every one of zero tents”), while the counting version would return none. Decide which answer the business expects.
H. Knowledge check
-
Understanding. Why does
WHERE EmployeeID NOT IN (SELECT ManagerID FROM HR.Employee)return no rows? -
Debugging. This query fails with error 512. Why, and how would you fix it to return each customer’s most recent order date?
SELECT c.CustomerID, (SELECT o.OrderDate FROM Sales.SalesOrder AS o WHERE o.CustomerID = c.CustomerID) AS LastOrder FROM Sales.Customer AS c; -
Query interpretation. What does this return, and why is the select list inside
EXISTSirrelevant?SELECT p.ProductCode FROM Inventory.Product AS p WHERE EXISTS (SELECT NULL FROM Inventory.Stock AS s WHERE s.ProductID = p.ProductID AND s.QuantityOnHand = 0); -
Debugging. This fails with No column name was specified for column 2 of ‘t’. Fix it.
SELECT t.CustomerID FROM (SELECT CustomerID, COUNT(*) FROM Sales.SalesOrder GROUP BY CustomerID) AS t; -
Understanding. A colleague writes
LEFT JOIN Sales.SalesOrder AS o ON o.CustomerID = c.CustomerID WHERE o.OrderDate >= '2025-12-01'to list all customers and their December orders. Why do customers without December orders disappear?
Show the answers
-
Employee 1’s
ManagerIDis NULL, so the list contains a NULL.x NOT IN (…, NULL)includesx <> NULL, which is UNKNOWN for every row, so no row is TRUE. UseNOT EXISTS, or addWHERE ManagerID IS NOT NULLto the subquery. -
The subquery returns one row per order, so for customers with several orders it returns several values where one is required. Aggregate it:
(SELECT MAX(o.OrderDate) FROM … WHERE o.CustomerID = c.CustomerID). -
Product codes for products that are out of stock (quantity 0) in at least one warehouse.
EXISTSonly checks whether the subquery returns any row; the values in its select list are never used, soSELECT NULL,SELECT 1andSELECT *behave the same. -
A derived table’s columns must all have names. Alias the aggregate:
COUNT(*) AS Orders. -
The
WHEREclause runs after the join. For customers with no December order, the right-hand columns are NULL,NULL >= '2025-12-01'is UNKNOWN, and the rows are removed — turning theLEFT JOINinto an inner join in effect. Move the date condition into theONclause:ON o.CustomerID = c.CustomerID AND o.OrderDate >= '2025-12-01'. Day 8 covers this in depth.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Subquery returned more than 1 value… (512) | A subquery used as a single value returned several rows | Aggregate (MAX, MIN), add a filter that guarantees one row, or use IN/EXISTS |
NOT IN returns no rows |
The subquery returns a NULL | Use NOT EXISTS, or filter NULLs inside the subquery |
| Correlated subquery returns the same value for every row | Unqualified columns resolved to the inner table (WHERE CategoryID = CategoryID) |
Alias both copies and qualify every column |
| Incorrect syntax near ‘)’ after a derived table | The derived table has no alias | Add AS t |
| No column name was specified for column N of ‘t’ | An expression in the derived table has no alias | Alias every computed column |
| The column ‘X’ was specified multiple times for ‘t’ | Two columns with the same name, e.g. o.OrderID and l.OrderID |
Select only one, or alias them differently |
| The ORDER BY clause is invalid in views, inline functions, derived tables, subqueries… | ORDER BY inside a derived table |
Move it to the outermost query |
A LEFT JOIN returns the same rows as an inner join |
A WHERE condition on the right-hand table removes the NULL rows |
Move that condition into ON |
| Anti-join returns too many rows | WHERE right.col IS NULL tested a nullable column |
Test the right-hand primary key |
Cleanup. Day 3 is read-only; nothing to clean up. Keep RetailLab unchanged for Day 4. Save your Task 3 query — you will rewrite it with CTEs tomorrow.
J. Completion checklist, recap and references
Completion checklist
- Worked example 2 returned 0 rows with
NOT INand 10 withNOT EXISTS, and you can explain why. - Worked example 7 shows average order values of 327.38 (Online) and 337.20 (Store).
- Your guided-lab query returns the 7 customers shown in step 4.
- Tasks 1–4 match the success criteria (1, 4, 19 and 35 rows).
- You can rewrite an
INsubquery asEXISTSand aNOT EXISTSas aLEFT JOIN … IS NULL.
Recap
- A scalar subquery must return at most one value; zero rows give NULL; several rows raise error 512.
INfilters without duplicating rows.NOT INfails silently when the list contains NULL — preferNOT EXISTS.- Correlated subqueries refer to the outer row; alias every table copy and qualify every column.
EXISTS/NOT EXISTStest for the presence of rows and are immune to NULL problems.- Derived tables let you aggregate in stages and filter on calculated columns; they need an alias and named columns, and they cannot contain
ORDER BY. LEFT JOINkeeps unmatched left rows with NULLs on the right. Anti-join:WHERE right.PrimaryKey IS NULL. Right-table filters belong inON.
Next: Day 4 — Common table expressions: the same layered logic, written top to bottom, plus recursive queries over the employee and category hierarchies.