The three previous lessons answered the what: what a subquery returns, whether it's correlated, whether it asks about existence. This one answers the where. Because the same subquery placed in the SELECT, in the FROM or in the WHERE doesn't do the same thing, doesn't cost the same and doesn't allow the same things.
And there's a clause you haven't used yet which is, by some distance, the most powerful: the FROM. A subquery there is called a derived table and it allows something no other construct in the course allowed: aggregating an already aggregated result. With it you'll finally compute the €36.40 average order value from its source, you'll cross two aggregates of different granularity so that the €727.95 of product and the €118.25 of shipping add up without inflating each other —the problem you've been carrying since module 3— and you'll meet LATERAL, the exception that lets a derived table be correlated.
Contents
- Summary table: what changes depending on where you put it
- In the
SELECT: the computed column - In the
FROM: the derived table LATERAL: the derived table that can be correlated- In the
WHEREand in theHAVING - In
INSERT,UPDATEandDELETE - Decision table: given a problem, where to put it
- Common Mistakes and Tips
- Exercises
- Conclusion
- Summary table: what changes depending on where you put it
| Clause | What it must return | Correlated? | Typical cost | Preferable alternative |
|---|---|---|---|---|
SELECT |
Scalar: 1 row, 1 column | Yes, and it almost always is | 1 run per row | LEFT JOIN + GROUP BY |
FROM |
Table: any shape | No, except with LATERAL |
1 (or 1 per row with LATERAL) |
CTE (10-02) with 3+ levels |
WHERE |
Scalar, or a list for IN/EXISTS |
Yes | 1, or 1 per row if it correlates | JOIN if you need its columns |
HAVING |
Scalar | Yes (per group) | 1 | — |
An UPDATE's SET |
Scalar | Yes | 1 per updated row | UPDATE ... FROM (05-03) |
Three rules follow from the table: only a value fits in the SELECT (two rows or two columns and the query blows up); in the FROM the alias is mandatory, always, even if you don't use it; and a derived table doesn't see the other tables of its own FROM, which is the restriction LATERAL lifts.
- In the
SELECT: the computed column
SELECT: the computed columnA scalar subquery in the SELECT behaves like just another column. You already used it in 07-02: what matters here is its limit and its alternative.
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
c.country,
(SELECT COUNT(*) FROM orders AS o WHERE o.customer_id = c.id) AS orders,
(SELECT COALESCE(ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2), 0)
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
WHERE o.customer_id = c.id) AS total_spent
FROM customers AS c
ORDER BY total_spent DESC, c.id
LIMIT 4;| id | customer | country | orders | total_spent |
|---|---|---|---|---|
| 7 | Sofia Moreira Costa | Portugal | 2 | 111.88 |
| 1 | Lucía Martínez Soler | Spain | 3 | 107.60 |
| 9 | Camille Dubois | France | 2 | 70.87 |
| 10 | Julien Moreau | France | 1 | 66.90 |
(First 4 of 15 rows.) The three customers with no orders close the list with 0 and 0.00, thanks to 06-04's COALESCE.
The three properties that define this use: it can only return one value (if you need the total and the number of orders, two subqueries are needed, and each walks orders on its own); it doesn't filter rows, all 15 customers are still there, behaving like a LEFT JOIN without being one; and it runs once per row: two subqueries × 15 customers = 30 runs.
The same query with LEFT JOIN and GROUP BY
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
c.country,
COUNT(DISTINCT o.id) AS orders,
COALESCE(ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2), 0) AS total_spent
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
LEFT JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY c.id, c.name, c.last_name, c.country
ORDER BY total_spent DESC, c.id
LIMIT 4;Identical result, the same 15 rows with the same values. But the work is very different:
Subqueries in the SELECT |
LEFT JOIN + GROUP BY |
|
|---|---|---|
Passes over orders |
30 (2 × 15 customers) | 1 |
| Readability with 2 columns / with 5 | Very good / poor | Good / good |
Risk of an inflated COUNT(*) |
None | High: you have to use COUNT(DISTINCT o.id) |
| Adding a new metric | Copy and paste a block | Add an aggregate |
Look at the second-to-last row, which is the important nuance: the JOIN version needs COUNT(DISTINCT o.id) because the second LEFT JOIN multiplies each order's rows by its lines. It's exactly 04-04's problem, and the subquery version is immune to it. Neither form is always better: with one or two columns the subquery reads better; from three onwards, the GROUP BY wins hands down. The full discussion is 07-05.
- In the
FROM: the derived table
FROM: the derived tableA subquery in the FROM produces a derived table: an intermediate result the outer query treats as a real table. It's the clause that unlocks the most useful pattern in analysis: aggregating twice.
The mandatory alias
-- ⚠️ INCORRECT
SELECT AVG(total) FROM (SELECT SUM(quantity) AS total FROM order_lines GROUP BY order_id);Every derived table needs a name, even if you don't use it: its columns have to be qualifiable (t.total), and without a table name that's impossible.
Dialect note: PostgreSQL, MySQL, MariaDB and SQL Server require the alias; SQLite and Oracle let you omit it. Always write it: it's portable and it makes the query readable.
Case 1: aggregating twice — the average order value, from its source
In 07-01 you computed the average order value as SUM(amount) / COUNT(DISTINCT order_id). That's correct, but it's a shortcut: the honest formulation is to compute each order's total and then average those totals. Those are two chained aggregations, and only a derived table allows them.
SELECT COUNT(*) AS orders,
ROUND(AVG(t.total), 2) AS avg_order_value,
ROUND(MIN(t.total), 2) AS min_order_value,
ROUND(MAX(t.total), 2) AS max_order_value,
ROUND(SUM(t.total), 2) AS revenue
FROM (SELECT o.id AS order_id,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY o.id) AS t;| orders | avg_order_value | min_order_value | max_order_value | revenue |
|---|---|---|---|---|
| 20 | 36.40 | 22.60 | 66.90 | 727.95 |
There are the course's canonical figures, and now by construction: 20 orders, an average of €36.40, revenue of €727.95; the cheapest order is Camille's number 20 and the most expensive Julien's number 12.
What makes the result possible is that the derived table changes the granularity: 47 lines go in and 20 orders come out, and the outer query aggregates over those 20. Without it, AVG over the lines would give the €15.49 average line amount, which is a different thing.
Case 2: aggregating and then filtering
Once you have the derived table, you filter it like any other table:
SELECT t.order_id, c.name || ' ' || c.last_name AS customer, t.total
FROM (SELECT o.id AS order_id, o.customer_id,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY o.id, o.customer_id) AS t
JOIN customers AS c ON c.id = t.customer_id
WHERE t.total > 36.40
ORDER BY t.total DESC;| order_id | customer | total |
|---|---|---|
| 12 | Julien Moreau | 66.90 |
| 8 | Sofia Moreira Costa | 64.88 |
| 10 | Camille Dubois | 48.27 |
| 17 | Sofia Moreira Costa | 47.00 |
(First 4 of 6 rows; Tiago's order 9, €44.60, and Lucía's order 1, €42.10, close the list.) 6 orders out of 20 beat the average order value. The filter is in the outer query's WHERE, not in a HAVING: as far as it's concerned, t.total is a normal column. A derived table turns aggregates into ordinary columns, and that simplifies the writing enormously.
Case 3: two aggregates of different granularity — the shipping problem
This is the case the course has had pending since module 3. Shipping costs live in orders (one per order) and amounts in order_lines (several per order): adding them up in the same query with a JOIN gave €278.70 of shipping instead of €118.25, because each order was counted as many times as it had lines. The solution is to aggregate each thing separately and join the already aggregated results:
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
ord.orders,
lin.products,
ord.shipping,
ROUND(lin.products + ord.shipping, 2) AS total
FROM customers AS c
JOIN (SELECT customer_id, COUNT(*) AS orders, SUM(shipping_cost) AS shipping
FROM orders GROUP BY customer_id) AS ord ON ord.customer_id = c.id
JOIN (SELECT o.customer_id,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS products
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY o.customer_id) AS lin ON lin.customer_id = c.id
ORDER BY total DESC;| id | customer | orders | products | shipping | total |
|---|---|---|---|---|---|
| 7 | Sofia Moreira Costa | 2 | 111.88 | 19.80 | 131.68 |
| 1 | Lucía Martínez Soler | 3 | 107.60 | 4.95 | 112.55 |
| 9 | Camille Dubois | 2 | 70.87 | 25.00 | 95.87 |
(First 3 of 12 rows.) And the columns add up: summed across the 12 rows they give €727.95 of product + €118.25 of shipping = €846.20, the course's three canonical figures. Not a cent inflated.
The key is that each derived table already arrives at customer granularity —ord has 12 rows and so does lin—, so when they're joined each order contributes its shipping only once: the SUM was done before the JOIN. The global version fits in three lines:
SELECT lin.revenue, ord.shipping, ROUND(lin.revenue + ord.shipping, 2) AS total
FROM (SELECT ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS revenue
FROM order_lines) AS lin
CROSS JOIN (SELECT SUM(shipping_cost) AS shipping FROM orders) AS ord;| revenue | shipping | total |
|---|---|---|
| 727.95 | 118.25 | 846.20 |
From three levels onwards, use CTEs. Two derived tables can still be read; with three, or with a derived table inside another inside another, the indentation eats the screen. The solution is to give each step a name with
WITH, and that's 10-02: everything in this section gets rewritten there with half the parentheses.
LATERAL: the derived table that can be correlated
LATERAL: the derived table that can be correlatedYou already know that a derived table doesn't see the other tables of its own FROM:
-- ⚠️ INCORRECT
SELECT cat.name, top.name FROM categories AS cat,
(SELECT p.name FROM products AS p WHERE p.category_id = cat.id LIMIT 2) AS top;ERROR: invalid reference to FROM-clause entry for table "cat" HINT: There is an entry for table "cat", but it cannot be referenced from this part of the query.
The keyword LATERAL lifts that restriction: it tells the engine "evaluate this subquery once for every row of what's to its left". It's a for loop over the previous table, and it combines with CROSS JOIN LATERAL or with LEFT JOIN LATERAL ... ON TRUE. Its natural case is top-N per group:
SELECT cat.id, cat.name AS category, top.product, top.units
FROM categories AS cat
CROSS JOIN LATERAL (
SELECT p.name AS product, SUM(ol.quantity) AS units
FROM products AS p
JOIN order_lines AS ol ON ol.product_id = p.id
WHERE p.category_id = cat.id
GROUP BY p.id, p.name
ORDER BY units DESC, p.id
LIMIT 2) AS top
ORDER BY cat.id, top.units DESC;| id | category | product | units |
|---|---|---|---|
| 1 | Food | Organic brown rice 1 kg | 14 |
| 1 | Food | Organic crushed tomato 400 g | 14 |
| 2 | Natural cosmetics | Calendula lip balm 15 ml | 7 |
| 3 | Sustainable home | Reusable cotton bags (pack of 5) | 4 |
| 4 | Drinks | Ginger kombucha 750 ml | 12 |
| 5 | Personal hygiene | Bamboo toothbrush | 9 |
(6 of the 9 rows.) The first four categories contribute their two best-selling products; Personal hygiene has only one sold (the toothbrush, because the deodorant was never sold) and contributes one row. And Supplements doesn't appear, because its subquery returns zero rows and CROSS JOIN LATERAL behaves like an INNER JOIN; to see it with NULLs you use LEFT JOIN LATERAL (...) AS top ON TRUE, which gives 10 rows. That ON TRUE isn't decoration: the syntax requires a join condition and the correlation is already inside, so there's nothing left to put.
| Ordinary derived table | LATERAL |
|
|---|---|---|
Sees the previous tables of the FROM |
No | Yes |
| Times it's evaluated | 1 | 1 per row on the left |
Allows a LIMIT per group |
No | Yes: that's its great advantage |
Dialect note:
LATERALis standard SQL:1999 and works in PostgreSQL 9.3+, MySQL 8.0.14+ and Oracle 12c+. In SQL Server the equivalent is calledCROSS APPLY(andOUTER APPLYfor the version withNULLs), with the same semantics and without the wordLATERAL. SQLite doesn't support it. And for this same problem there's a third route, often better:ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY units DESC)filtering by<= 2, which is 10-03.
- In the
WHERE and in the HAVING
WHERE and in the HAVINGThis is 07-01's and 07-03's territory: in the WHERE you can put scalar subqueries (> (SELECT AVG(...))), list ones (IN, ANY, ALL) and existence ones (EXISTS), correlated or not; and if you need columns from the inner table in the result, that isn't a WHERE, it's a JOIN. The HAVING deserves an example of its own, because its subquery compares a group's aggregate with a value computed over a different set:
SELECT cat.id, cat.name AS category,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM order_lines AS ol
JOIN products AS p ON ol.product_id = p.id
JOIN categories AS cat ON p.category_id = cat.id
GROUP BY cat.id, cat.name
HAVING SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
> (SELECT SUM(ol2.quantity * ol2.unit_price * (1 - ol2.discount))
/ COUNT(DISTINCT p2.category_id)
FROM order_lines AS ol2
JOIN products AS p2 ON ol2.product_id = p2.id)
ORDER BY revenue DESC;| id | category | revenue |
|---|---|---|
| 1 | Food | 256.27 |
| 4 | Drinks | 195.28 |
| 2 | Natural cosmetics | 156.32 |
3 categories out of the 5 with sales beat the average revenue per category, which is €145.59 (€727.945 across the 5 categories that have sold anything). Sustainable home (€88.58) and Personal hygiene (€31.50) fall below, and Supplements doesn't even reach the GROUP BY because the INNER JOIN discarded it.
Look at the divisor: COUNT(DISTINCT p2.category_id) counts 5, not 6. To divide by the catalogue's six categories, the divisor would have to come from categories, not from the sales. The average depends on what you compute it over.
- In
INSERT, UPDATE and DELETE
INSERT, UPDATE and DELETEModule 5 taught you the three statements; now you can give them a subquery in any of their clauses.
-- 1. In an UPDATE's WHERE: raise the drinks by 10 %
UPDATE products SET price = ROUND(price * 1.10, 2)
WHERE category_id = (SELECT id FROM categories WHERE name = 'Drinks');
-- 2. CORRELATED in the SET: align the price with the average actually sold
UPDATE products AS p
SET price = (SELECT ROUND(AVG(ol.unit_price), 2)
FROM order_lines AS ol WHERE ol.product_id = p.id)
WHERE EXISTS (SELECT 1 FROM order_lines AS ol WHERE ol.product_id = p.id);
-- 3. In a DELETE: remove reviews of discontinued products
DELETE FROM reviews AS r
WHERE EXISTS (SELECT 1 FROM products AS p WHERE p.id = r.product_id AND p.active = FALSE);The second one hides the lesson's most dangerous trap. Without the final WHERE EXISTS, the three products never sold would receive the result of a subquery with no rows —that is, NULL— and price is NOT NULL: the statement would fail outright with null value in column "price" violates not-null constraint. If the column allowed nulls it would be worse: it would be left as NULL without saying a thing. The 05-03 rule is extended: in an UPDATE with a subquery in the SET, the WHERE must guarantee that the subquery finds something.
- Decision table: given a problem, where to put it
| What you want | Where the subquery goes | Form |
|---|---|---|
| Filter by a computed threshold or by membership of a list | WHERE |
> (SELECT AVG(...)), IN (SELECT ...) |
| Filter by presence or absence | WHERE |
EXISTS / NOT EXISTS |
| Filter groups by a global value | HAVING |
> (SELECT ...) |
| Add a value computed per row (or several) | SELECT, correlated scalar; with 3+, LEFT JOIN + GROUP BY |
— |
| Aggregate over an aggregate | FROM |
Derived table |
| Cross metrics of different granularity | FROM (two derived tables) |
JOIN between them |
| A "top N" for each row of another table | FROM |
LATERAL |
| Reuse the same calculation three times | None: CTE | WITH (10-02) |
Common Mistakes and Tips
- Forgetting a derived table's alias.
subquery in FROM must have an alias. Always put it, even if you don't use it. And referencing another table of the sameFROMfrom a derived table givesinvalid reference to FROM-clause entry: that's whatLATERALis for. - Putting a multi-row subquery in the
SELECT.more than one row returned by a subquery used as an expression: only one value fits there. - Summing
shipping_costafter joining withorder_lines. It's 04-04's mistake —€278.70 instead of €118.25—: aggregate each thing separately in two derived tables. - Confusing the average order value (€36.40, the average of 20 orders) with the average line amount (€15.49, the average of 47). Only the derived table gives the first. And counting with
COUNT(*)after severalLEFT JOINs inflates: each order appears as many times as it has lines, soCOUNT(DISTINCT o.id). - An
UPDATEwith a subquery in theSETand noWHEREnarrowing it. The rows with no match receiveNULL: either the constraint blows up, or the data is silently corrupted. - Tip: build derived tables from the inside outwards. Write the subquery on its own, run it, check how many rows and what granularity it has, and only then wrap it: a derived table can always be run in isolation (except with
LATERAL). - Tip: name it after what it contains, not
t1andt2:ord,lin,sales_per_customer. And count the rows at each level —47 lines → 20 orders → 1 row—: if a level doesn't reduce things the way you expected, the error is there and not in the one above.
Exercises
Exercise 1
Management wants the average order value by country: for each country, the number of orders, the product revenue and the average order value (the average of its orders' totals). Use a derived table. Then answer: would the result be different if you computed SUM(amount) / COUNT(DISTINCT order_id) with no derived table?
Exercise 2
A colleague wants the report "per customer: orders, lines, units, distinct products and revenue" and has started like this:
SELECT c.id, c.name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS orders,
(SELECT COUNT(*) FROM orders o JOIN order_lines ol ON ol.order_id = o.id
WHERE o.customer_id = c.id) AS lines,
(SELECT SUM(ol.quantity) FROM orders o JOIN order_lines ol ON ol.order_id = o.id
WHERE o.customer_id = c.id) AS units
FROM customers c;- How many subquery runs does it involve as it stands, and how many if they add the two missing columns?
- Rewrite it with a
LEFT JOINandGROUP BY, taking care with the order count. - What difference will there be in the result for customers 13, 14 and 15?
Exercise 3
Marketing wants, for each customer who has bought, their most expensive order: order id, date and amount. Write it with LATERAL and explain why an ordinary derived table wouldn't do.
Solutions
Solution 1
SELECT t.country,
COUNT(*) AS orders,
ROUND(SUM(t.total), 2) AS revenue,
ROUND(AVG(t.total), 2) AS avg_order_value
FROM (SELECT o.id, c.country,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY o.id, c.country) AS t
GROUP BY t.country
ORDER BY avg_order_value DESC;| country | orders | revenue | avg_order_value |
|---|---|---|---|
| Portugal | 3 | 156.48 | 52.16 |
| France | 3 | 137.77 | 45.92 |
| Spain | 14 | 433.70 | 30.98 |
3 + 3 + 14 = 20 orders and 156.48 + 137.77 + 433.70 = €727.95: it squares with the canonical figures. And the reading confirms what you already saw in 07-01: foreign orders are appreciably bigger —€52.16 and €45.92 against €30.98—, consistent with shipping costs of €9.90 and €12.50 that push customers to bundle their purchases.
On the question: here the result would be the same, because AVG of the per-order totals and SUM(amount) / COUNT(DISTINCT order_id) are arithmetically identical when each order belongs to a single country. The derived table wins anyway for two reasons: it reads far better —it literally says "the average of the orders' totals"— and it lets you compute what the shortcut can't, such as MIN(t.total), MAX(t.total) or the median.
Solution 2
1. The runs. Three subqueries × 15 customers = 45; with the two missing columns, 75. And all five walk the same two tables with the same filter: five times the same work. 2. The rewrite:
SELECT c.id,
c.name,
COUNT(DISTINCT o.id) AS orders,
COUNT(ol.id) AS lines,
COALESCE(SUM(ol.quantity), 0) AS units,
COUNT(DISTINCT ol.product_id) AS distinct_products,
COALESCE(ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2), 0) AS revenue
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
LEFT JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;| id | name | orders | lines | units | distinct_products | revenue |
|---|---|---|---|---|---|---|
| 1 | Lucía | 3 | 9 | 15 | 8 | 107.60 |
| 7 | Sofia | 2 | 5 | 11 | 4 | 111.88 |
| 13 | Núria | 0 | 0 | 0 | 0 | 0.00 |
(3 of the 15 rows, as a sample.) COUNT(DISTINCT o.id) is mandatory: with a plain COUNT(o.id), Lucía would have 9 orders instead of 3, one per line. It's exactly 04-04's trap. COUNT(ol.id), on the other hand, does go without DISTINCT, because each line is unique.
3. Customers 13, 14 and 15 appear in both versions with the same values: 0 where there's a COUNT and 0.00 where the COALESCE covers SUM's NULL. Neither the subquery in the SELECT nor the LEFT JOIN removes them; what would have removed them is an INNER JOIN, and that's why the LEFT isn't negotiable here.
Solution 3
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
mx.order_id, mx.order_date, mx.total
FROM customers AS c
CROSS JOIN LATERAL (
SELECT o.id AS order_id, o.order_date,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
WHERE o.customer_id = c.id
GROUP BY o.id, o.order_date
ORDER BY total DESC, o.id
LIMIT 1) AS mx
ORDER BY mx.total DESC, c.id;| id | customer | order_id | order_date | total |
|---|---|---|---|---|
| 10 | Julien Moreau | 12 | 2025-10-01 | 66.90 |
| 7 | Sofia Moreira Costa | 8 | 2025-06-28 | 64.88 |
| 9 | Camille Dubois | 10 | 2025-08-03 | 48.27 |
(First 3 of 12 rows.) Customers 13, 14 and 15 don't appear: their lateral subquery returns zero rows and CROSS JOIN LATERAL discards them, which is exactly what the question asked for. With LEFT JOIN LATERAL ... ON TRUE all 15 rows would come out, with NULLs in the last three.
Why an ordinary derived table won't do: we'd need to write WHERE o.customer_id = c.id inside it, and an ordinary derived table doesn't see c (invalid reference to FROM-clause entry for table "c"). You could work around it by aggregating per customer and joining on MAX(total), but that forces a second JOIN to recover the winning order's id and date, and it duplicates rows if there's a tie. LATERAL solves it in one pass because the LIMIT 1 is applied per customer, and no other construct in the module can do that. The modern alternative is ROW_NUMBER() (10-03).
Conclusion
The where matters as much as the what:
- In the
SELECT, a scalar subquery is a computed column: it doesn't filter rows and it runs once per row. With one or two columns it reads very well; from three onwardsLEFT JOIN+GROUP BYwins, taking care with theCOUNT(DISTINCT). - In the
FROM, a subquery is a derived table and needs an alias without exception (subquery in FROM must have an alias). It's the only way to aggregate over an aggregate: 47 lines → 20 orders → an average order value of €36.40, with a minimum of €22.60 and a maximum of €66.90. - Two derived tables of different granularity solve the shipping problem you'd been dragging along since module 3: €727.95 of product + €118.25 of shipping = €846.20, with nothing inflated, because each
SUMis done before theJOIN. LATERALis the exception that lets a derived table be correlated, and its natural case is top N per group: the two best-selling products of each category, with theLIMITapplied per category. In SQL Server it's calledCROSS APPLY; in SQLite it doesn't exist; and for rankings a window function is usually better (10-03).- In
WHEREandHAVINGeverything from 07-01 and 07-03 applies; in anUPDATE, a subquery in theSETdemands aWHEREguaranteeing it finds something, or you'll write nulls. And from three levels onwards readability demands CTEs (10-02).
You now have all the pieces: scalar subqueries, list subqueries, correlated ones, EXISTS, derived tables and LATERAL. And with them, a new problem: almost every question now admits two or three different forms, and none of them tells you which to choose. In the module's last lesson, subquery or JOIN: which one to choose, you'll see the four great equivalences head to head, the real difference between IN and an INNER JOIN —which isn't one of style, but of row count—, the definitive table of the four ways of answering "what doesn't match" and the honest criterion for deciding: readability first, measured performance afterwards.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
