All the subqueries in 07-01 had something in common: they were computed once and were worth the same for every row. That's why none of them could answer the question left open at the end: which products beat the average of their category? That threshold isn't one, it's six —€6.18 for Food, €11.54 for Natural cosmetics, €8.90 for Drinks…— and each row needs its own.
A correlated subquery does exactly that: it looks outwards, at the row the outer query is examining at that instant, and it's recomputed for each one. The mental model changes (it stops being a constant and becomes a loop), the cost changes and a whole family of questions opens up: each customer's last order, the amount of their most expensive order, each product's most recent review.
Contents
- How to recognise a correlated subquery
- The mental model: one run per row
- The canonical case: each product against its category's average
- The variant: the most expensive in its category, and self-correlation
- Four more GreenStore cases
- Alias scope: who sees whom
- The cost: N runs and what the planner does
- When to use it and when it gives away that something else is missing
- Common Mistakes and Tips
- Exercises
- Conclusion
- How to recognise a correlated subquery
There's just one signal: inside the subquery there's an alias belonging to the outer query.
-- Non-correlated: everything it mentions is in its own FROM
SELECT AVG(price) FROM products;
-- Correlated: p.category_id isn't in its FROM, it comes from outside
SELECT AVG(price) FROM products WHERE category_id = p.category_id;Copy that second query into psql and run it on its own:
ERROR: missing FROM-clause entry for table "p"
LINE 1: ...T AVG(price) FROM products WHERE category_id = p.category_id;
^That error is the diagnosis, not a problem: it confirms that the subquery depends on its surroundings and only makes sense inside the query containing it. It's 07-01's practical test, now seen from the other side.
| Non-correlated | Correlated | |
|---|---|---|
| Mentions outer aliases | No | Yes |
| Run on its own | Works | missing FROM-clause entry |
| Evaluations | 1 | 1 per candidate row |
| Behaves like | A constant | A function of the outer row |
- The mental model: one run per row
Extend the logical-order diagram you've been building since module 2. What's new happens inside step 2: for every row reaching the WHERE, the correlated subquery runs in full and returns its value.
flowchart LR
A["1 · FROM / JOIN"] --> B["2 · WHERE<br/>row by row"]
B --> S{{"for EVERY row:<br/>run the subquery<br/>with that row's values"}}
S --> B
B --> C["3 · GROUP BY"] --> D["4 · HAVING"] --> E["5 · SELECT"] --> F["6 · ORDER BY"] --> G["7 · LIMIT"]
Conceptually it's a nested loop: the outer query walks its rows and, on each iteration, fires off the inner query. If you put three correlated subqueries in the SELECT and the outer table has 15 rows, that's 45 runs.
A trace of the first rows of products, with the subquery AVG(price) of its category:
| Outer row | p.category_id |
Subquery run | Returns | price > ? |
|---|---|---|---|---|
| 1 · Olive oil, €12.50 | 1 | AVG(price) WHERE category_id = 1 |
6.18 | yes |
| 2 · Rice, €3.90 | 1 | AVG(price) WHERE category_id = 1 |
6.18 | no |
| 3 · Honey, €9.75 | 1 | AVG(price) WHERE category_id = 1 |
6.18 | yes |
| 4 · Spelt pasta, €2.80 | 1 | AVG(price) WHERE category_id = 1 |
6.18 | no |
| 6 · Aloe cream, €18.90 | 2 | AVG(price) WHERE category_id = 2 |
11.5375 | yes |
Look at the first four rows: the same calculation repeated four times. That apparent waste is the reason for section 7, and also the reason modern planners rewrite many correlated subqueries.
- The canonical case: each product against its category's average
SELECT p.id,
p.name AS product,
cat.name AS category,
p.price,
ROUND((SELECT AVG(p2.price)
FROM products AS p2
WHERE p2.category_id = p.category_id), 2) AS category_avg
FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id
WHERE p.price > (SELECT AVG(p2.price)
FROM products AS p2
WHERE p2.category_id = p.category_id)
ORDER BY p.id;| id | product | category | price | category_avg |
|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | Food | 12.50 | 6.18 |
| 3 | Raw orange blossom honey 500 g | Food | 9.75 | 6.18 |
| 6 | Aloe vera face cream 50 ml | Natural cosmetics | 18.90 | 11.54 |
| 8 | Almond body oil 200 ml | Natural cosmetics | 14.25 | 11.54 |
| 10 | Concentrated eco laundry detergent 1 L | Sustainable home | 11.20 | 10.09 |
| 13 | Soy wax candles (pack of 2) | Sustainable home | 13.75 | 10.09 |
| 15 | Ceremonial matcha green tea 30 g | Drinks | 22.00 | 8.90 |
| 19 | Natural stick deodorant 50 g | Personal hygiene | 7.80 | 5.65 |
8 products out of 20, and the comparison with 07-01 is very instructive: with the global average (€9.035) 9 products came out. They aren't 9 nor a subset of them:
| Product | Beats the global average (9.035)? | Beats its category's average? |
|---|---|---|
| 19 · Deodorant, €7.80 | No | Yes (5.65 for Personal hygiene) |
| 3 · Honey, €9.75 | Yes | Yes (6.18 for Food) |
| 12 · Bags, €9.90 | Yes | No (10.09 for Sustainable home) |
| 20 · Spirulina, €16.40 | Yes | No (it's the only Supplements product: it is its own average) |
The deodorant is cheap in absolute terms but expensive within its category; the bags are exactly the opposite. And the spirulina case is the funniest: it's the only Supplements product, so its category's average is its own price, and 16.40 > 16.40 is false. A product alone in its category can never beat its category's average.
Two writing details worth fixing:
- The expression is repeated in the
SELECTand in theWHERE, exactly as happened withHAVINGin 04-06. It's mandatory (theSELECT's aliases don't exist in theWHERE) and it's ugly. The clean solution is 10-02's CTEs. - The aliases
pandp2are indispensable. Inside and outside it's the sameproductstable, and without distinct aliasesWHERE category_id = category_idwould be a tautology: the subquery would ignore the category, would return the global average and the query would stop being correlated without raising any error. We'll come back to it in exercise 3.
- The variant: the most expensive in its category, and self-correlation
Swapping AVG for MAX and > for =, the same structure answers another classic question:
SELECT p.id, p.name AS product, cat.name AS category, p.price
FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id
WHERE p.price = (SELECT MAX(p2.price)
FROM products AS p2
WHERE p2.category_id = p.category_id)
ORDER BY p.id;| id | product | category | price |
|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | Food | 12.50 |
| 6 | Aloe vera face cream 50 ml | Natural cosmetics | 18.90 |
| 13 | Soy wax candles (pack of 2) | Sustainable home | 13.75 |
| 15 | Ceremonial matcha green tea 30 g | Drinks | 22.00 |
| 19 | Natural stick deodorant 50 g | Personal hygiene | 7.80 |
| 20 | Spirulina capsules 120 units | Supplements | 16.40 |
6 rows, one per category. It's the greatest-n-per-group pattern, probably the most requested in all of analytical SQL.
Here the outer and the inner table are the same one, and that links straight back to 03-06's self join: just as there you joined employees with employees to get everybody's manager, here you compare products with products. The difference is that a self join produces pairs of rows and self-correlation produces a computed value for each row. When what you want is an aggregate per group, the correlated form usually reads better.
And a warning about ties: if two products of the same category shared the maximum price, both would come out, because both satisfy the equality. That's almost always what you want. If you needed exactly one per category, or the second one, or a full ranking, the right tool is no longer this one: it's the window functions (ROW_NUMBER(), RANK()) of 10-03.
- Four more GreenStore cases
A correlated subquery can also go in the SELECT, as a computed column. This query answers three questions at once:
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
(SELECT COUNT(*) FROM orders AS o WHERE o.customer_id = c.id) AS orders,
(SELECT MAX(o.order_date) FROM orders AS o WHERE o.customer_id = c.id) AS last_order,
(SELECT ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
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
ORDER BY 1 DESC
LIMIT 1) AS largest_order
FROM customers AS c
ORDER BY c.id;| id | customer | orders | last_order | largest_order |
|---|---|---|---|---|
| 1 | Lucía Martínez Soler | 3 | 2025-12-02 | 42.10 |
| 2 | Carlos Ferrer Ibáñez | 2 | 2025-09-09 | 32.76 |
| 3 | Marta Sanchis Gil | 1 | 2025-04-02 | 29.53 |
| 4 | Javier Ortega Ruiz | 2 | 2025-12-19 | 31.75 |
| 5 | Ana Belmonte Roca | 2 | 2026-01-27 | 28.10 |
| 6 | Pau Llorens Vidal | 2 | 2026-02-09 | 30.60 |
| 7 | Sofia Moreira Costa | 2 | 2026-01-13 | 64.88 |
| 8 | Tiago Almeida Nunes | 1 | 2025-07-15 | 44.60 |
| 9 | Camille Dubois | 2 | 2026-02-21 | 48.27 |
| 10 | Julien Moreau | 1 | 2025-10-01 | 66.90 |
| 11 | Elena Navarro Puig | 1 | 2025-10-22 | 30.30 |
| 12 | Diego Ramos Herrera | 1 | 2025-11-14 | 31.70 |
| 13 | Núria Bosch Ferrer | 0 | (null) | (null) |
| 14 | Hugo Iglesias Pardo | 0 | (null) | (null) |
| 15 | Inés Carrasco Vega | 0 | (null) | (null) |
All 15 customers, including the three who have never bought. That's an enormous difference from an INNER JOIN, which would have returned 12 rows: a subquery in the SELECT doesn't remove rows from the outer query; it returns a value or NULL, but the row is still there. It behaves like a LEFT JOIN without being one.
And notice the asymmetry in the last three rows: orders is 0 while the other two are NULL. It isn't an inconsistency, it's 04-04: COUNT over an empty set returns 0; MAX and SUM return NULL. If that column is going to feed a calculation, wrap it in COALESCE (06-04).
Fourth case: each product's most recent review. Here the correlation filters by product and LIMIT 1 trims:
SELECT p.id, p.name AS product,
(SELECT r.date FROM reviews AS r WHERE r.product_id = p.id
ORDER BY r.date DESC LIMIT 1) AS last_review,
(SELECT r.rating FROM reviews AS r WHERE r.product_id = p.id
ORDER BY r.date DESC LIMIT 1) AS rating
FROM products AS p
WHERE EXISTS (SELECT 1 FROM reviews AS r WHERE r.product_id = p.id)
ORDER BY p.id;| id | product | last_review | rating |
|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 2025-07-08 | 5 |
| 2 | Organic brown rice 1 kg | 2025-09-19 | 5 |
| 5 | Organic crushed tomato 400 g | 2025-04-12 | 3 |
| 6 | Aloe vera face cream 50 ml | 2025-08-14 | 4 |
| 10 | Concentrated eco laundry detergent 1 L | 2026-01-10 | 4 |
| 12 | Reusable cotton bags (pack of 5) | 2025-11-03 | 3 |
| 15 | Ceremonial matcha green tea 30 g | 2025-05-02 | 5 |
| 16 | Ginger kombucha 750 ml | 2025-06-20 | 2 |
| 18 | Bamboo toothbrush | 2025-07-26 | 4 |
9 products, the only ones with any review; the other 11 are excluded by the EXISTS (next lesson). And here a real flaw in this pattern shows up: there are two almost identical subqueries to get two columns from the same row, that is, twice the work. That's solved with LATERAL (07-04) or with window functions (10-03); with a single column the form above is perfectly reasonable.
- Alias scope: who sees whom
The rule is asymmetric and has to be memorised:
The subquery sees the outer query's aliases. The outer query does NOT see the subquery's aliases.
-- ⚠️ INCORRECT: p2 doesn't exist outside the subquery
SELECT p.id, p.name, p2.price
FROM products AS p
WHERE p.price > (SELECT AVG(p2.price) FROM products AS p2 WHERE p2.category_id = p.category_id);Visibility goes from inside outwards, never the other way round. Think of each subquery as a function receiving the outer row as a parameter: it can read what it's handed, but what happens inside it isn't exported. If you need a column from the inner table in the result, the answer isn't a subquery: it's a JOIN (07-05).
Two practical consequences:
- When the inner and the outer table are the same one, the aliases are mandatory.
products AS poutside,products AS p2inside. Without them,WHERE category_id = category_idresolves inside the subquery and is always true. - If a column name exists in both tables and you don't qualify it, the inner one wins. It's SQL's name resolution rule: the closest scope first, then the outer ones. It's an extremely subtle way of writing a query that "works" and answers a different question. Always qualify every column inside a correlated subquery.
- The cost: N runs and what the planner does
Conceptually, a correlated subquery is a nested loop: N outer rows × 1 inner run. With products that's 20 runs; with a table of two million rows, two million. And if the subquery also does a JOIN, the work multiplies.
Conceptually. Because PostgreSQL doesn't necessarily run it that way. The planner rewrites many correlated subqueries into equivalent and far cheaper forms:
| Written form | What it usually turns it into | Effect |
|---|---|---|
Correlated EXISTS (...) |
Semi-join (hash or merge) | One pass, not N |
NOT EXISTS (...) |
Anti-join | One pass |
IN (SELECT ...) |
Semi-join | One pass |
Correlated aggregate in the WHERE |
Sometimes, aggregation + join | It depends |
Correlated aggregate in the SELECT |
Almost never: it runs per row | N real runs |
The last row is the one that matters: correlated subqueries in the SELECT are the ones that most often stay a loop. With 15 customers it makes no difference; with 15 million, a query with three subqueries in the SELECT can take minutes where a LEFT JOIN with GROUP BY takes seconds.
Really checking it —seeing the plan, measuring the time, knowing whether there was a semi-join or a loop— needs EXPLAIN ANALYZE, and that's lesson 08-05. Until then, keep the intuition and this rule: don't rewrite for performance without measuring, but be suspicious of correlated subqueries in the SELECT over large tables.
- When to use it and when it gives away that something else is missing
| Situation | Correlated? |
|---|---|
| Compare each row with an aggregate of its group | Yes, that's its natural case |
Ask whether something related exists (EXISTS) |
Yes, always (07-03) |
| Bring "the latest", "the first", "the maximum" for each row | Yes, or LATERAL (07-04) |
| One or two computed columns over a small table | Yes, it reads very well |
| Five computed columns over the same related table | No: it's a LEFT JOIN + GROUP BY in disguise |
| A ranking, a "top 3 per group", a running total | No: window functions (10-03) |
| You need columns from the inner table in the result | No: it's a JOIN (07-05) |
The two alarm signals are clear. If you repeat the same correlation three or four times in the SELECT, you're walking the same table three or four times to group by the same key: that's a GROUP BY. And if the words "ranking", "position", "the second one" or "running total" show up, no subquery will do it elegantly; those are window functions.
Rewrite 1: the average per category, with a derived table
SELECT p.id, p.name AS product, cat.name AS category,
p.price, ROUND(m.avg_price, 2) AS category_avg
FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id
JOIN (SELECT category_id, AVG(price) AS avg_price
FROM products GROUP BY category_id) AS m ON m.category_id = p.category_id
WHERE p.price > m.avg_price
ORDER BY p.id;It returns exactly the same 8 rows as section 3. The difference is that each category's average is computed only once —six averages, in one pass— instead of twenty times. On top of that the expression stops being duplicated: it's named m.avg_price and used twice. That subquery in the FROM is called a derived table and it's 07-04's material.
Rewrite 2: the order count, with LEFT JOIN and GROUP BY
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
COUNT(o.id) AS orders,
MAX(o.order_date) AS last_order
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name, c.last_name
ORDER BY c.id;It returns the same 15 rows as section 5's first two columns, with the same 0s and NULLs. A single pass over orders instead of 30 subqueries. When the correlated columns start piling up, this is the rewrite that's called for. The honest comparison of both forms —readability, rows, performance— is 07-05.
Common Mistakes and Tips
- Forgetting the aliases when the table is the same inside and outside.
WHERE category_id = category_idis a tautology: the correlation disappears, the subquery computes the global average and there's no error at all. - Not qualifying the columns inside the subquery. If the name exists in both tables, the inner scope wins and the query silently answers a different question.
- Trying to use an inner alias in the outer query.
missing FROM-clause entry for table "p2". Visibility only goes from inside outwards. - Running the subquery on its own to debug it. You can't: it'll give that same error. To test it, substitute the outer value by hand (
WHERE p2.category_id = 1) and check it returns what you expect. - Expecting a subquery in the
SELECTto filter rows. It doesn't filter: it returns a value orNULL. All 15 customers still appear. - Confusing
COUNT's0withMAXorSUM'sNULL. A customer with no orders has 0 orders andNULLin any other aggregated column. - Writing a correlated scalar that returns several rows.
more than one row returned by a subquery used as an expression: the correlation narrows things down, but doesn't guarantee uniqueness. Add an aggregate or anORDER BY ... LIMIT 1. - Tip: write the subquery with a fixed value first. Check that
AVG(price) WHERE category_id = 1gives 6.18 and then replace the1withp.category_id. Debugging both layers at once is needlessly hard. - Tip: if you repeat the same correlation in two columns, move to
LATERALor toGROUP BY. Two identical subqueries to get two fields from the same row are twice the work for nothing. - Tip: count how many times it'll run. Rows of the outer table × correlated subqueries. If the number makes you uncomfortable, consider section 8's rewrite before production does it for you.
Exercises
Exercise 1
Quality wants to know which products have an average rating above the overall average of all reviews. Write the query with a non-correlated subquery for the overall average and show id, product, number of reviews and average (two decimals), sorted by average descending and id.
Then answer: is any part of your query correlated? Why would the answer change if the question were "an average above the average of its category"?
Exercise 2
Sales needs, for each customer who has bought, the amount of their last order (the one with the most recent date), along with their name and that date. Use correlated subqueries. Then say: what would happen if a customer had two orders on the same day, and how would you fix it?
Exercise 3
A colleague has written this to get "the products above their category's average" and is puzzled that it returns the same 9 rows as 07-01's query with the global average, instead of 8:
-- ⚠️ INCORRECT
SELECT id, name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products WHERE category_id = category_id);- Why does it return 9 rows and not 8?
- Fix it.
- What would the result have been if instead of
category_id = category_idthey'd writtencategory_id = supplier_id? Explain the mechanism; the exact number isn't needed.
Solutions
Solution 1
SELECT p.id,
p.name AS product,
COUNT(*) AS reviews,
ROUND(AVG(r.rating), 2) AS avg_rating
FROM reviews AS r
JOIN products AS p ON r.product_id = p.id
GROUP BY p.id, p.name
HAVING AVG(r.rating) > (SELECT AVG(rating) FROM reviews)
ORDER BY avg_rating DESC, p.id;| id | product | reviews | avg_rating |
|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 2 | 5.00 |
| 15 | Ceremonial matcha green tea 30 g | 1 | 5.00 |
| 2 | Organic brown rice 1 kg | 2 | 4.50 |
| 6 | Aloe vera face cream 50 ml | 2 | 4.50 |
4 products out of the 9 reviewed beat the overall average, which is 4.0833… (49 points across 12 reviews). Left out are the detergent and the toothbrush (4.00), the tomato and the bags (3.00) and the kombucha (2.00).
There's no correlation anywhere: the subquery SELECT AVG(rating) FROM reviews mentions nothing from outside, runs once and gives a number. It's the same pattern as 07-01's average order value. If the question were "above the average of its category", the subquery would have to filter by the category of the current group's product —WHERE p2.category_id = p.category_id— and would become correlated, evaluated once per group.
Solution 2
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
(SELECT MAX(o.order_date) FROM orders AS o WHERE o.customer_id = c.id) AS date,
(SELECT ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
WHERE o.customer_id = c.id
AND o.order_date = (SELECT MAX(o2.order_date)
FROM orders AS o2 WHERE o2.customer_id = c.id)) AS amount
FROM customers AS c
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id)
ORDER BY c.id;| id | customer | date | amount |
|---|---|---|---|
| 1 | Lucía Martínez Soler | 2025-12-02 | 33.40 |
| 2 | Carlos Ferrer Ibáñez | 2025-09-09 | 32.76 |
| 3 | Marta Sanchis Gil | 2025-04-02 | 29.53 |
| 4 | Javier Ortega Ruiz | 2025-12-19 | 31.18 |
| 5 | Ana Belmonte Roca | 2026-01-27 | 28.10 |
| 6 | Pau Llorens Vidal | 2026-02-09 | 26.73 |
| 7 | Sofia Moreira Costa | 2026-01-13 | 47.00 |
| 8 | Tiago Almeida Nunes | 2025-07-15 | 44.60 |
| 9 | Camille Dubois | 2026-02-21 | 22.60 |
| 10 | Julien Moreau | 2025-10-01 | 66.90 |
| 11 | Elena Navarro Puig | 2025-10-22 | 30.30 |
| 12 | Diego Ramos Herrera | 2025-11-14 | 31.70 |
12 rows. There are three levels of nesting and two different correlations against c.id. Compare the result with section 5's: Lucía's last order is worth €33.40 while her most expensive order is worth €42.10; Camille's is €22.60 against €48.27. They're different questions and it's worth not mixing them up in a report.
If a customer had two orders on the same day, the amount subquery would add up both and return an inflated total (it wouldn't error, because the GROUP BY isn't there and SUM aggregates everything it's given). The fix is to break the tie with the primary key: instead of filtering by order_date = MAX(...), filter by o.id = (SELECT o2.id FROM orders AS o2 WHERE o2.customer_id = c.id ORDER BY o2.order_date DESC, o2.id DESC LIMIT 1). Any "the latest" based only on a date with no time is fragile; always add a deterministic tie-breaker.
Solution 3
1. Because category_id = category_id resolves entirely inside the subquery: there's no alias distinguishing the outer table from the inner one, so both occurrences refer to the inner products. The condition is "a column equal to itself", true for all 20 rows, and the subquery ends up returning the global average, €9.035 — hence the 9 rows. The query isn't correlated at all, even though it looks like it, and that's the flaw: the category filter is never applied. It's a textbook silent error.
2. The fix is section 3's: distinct aliases inside and outside.
-- ✅ CORRECT
SELECT p.id, p.name, p.price
FROM products AS p
WHERE p.price > (SELECT AVG(p2.price) FROM products AS p2
WHERE p2.category_id = p.category_id);8 rows, section 3's.
3. category_id = supplier_id doesn't correlate either: it compares two columns of the same inner table. The subquery would return the average of the products whose category happens to match their supplier numerically —a condition with no business meaning whatsoever but perfectly valid— and that single number would apply to all 20 rows. It's the worst of the cases: a query that raises no error, looks correlated, and answers a question nobody asked. Hence the rule of always qualifying columns.
Conclusion
Correlation changes the nature of a subquery:
- A correlated subquery references an alias of the outer query; it can't be run on its own (
missing FROM-clause entry) and it's evaluated once for every candidate row, like a nested loop. - The canonical case is comparing each row with an aggregate of its own group: 8 products beat their category's average, against the 9 that beat the global average — and they aren't the same ones, because the deodorant is expensive for Personal hygiene and the bags are cheap for Sustainable home.
- The
= MAX(...)variant gives the most expensive in each category (6 rows, one per category), the greatest-n-per-group pattern, sibling of self-correlation and of 03-06's self join. - In the
SELECTit works as a computed column and doesn't remove rows: all 15 customers are still there, with0inCOUNTandNULLinMAXandSUM. - Visibility is asymmetric: the subquery sees the outer aliases, the outer query doesn't see the inner ones. When the table is the same, distinct aliases are mandatory: without them the correlation silently evaporates.
- Conceptually it costs N runs. PostgreSQL rewrites many as semi-joins or anti-joins, but correlated subqueries in the
SELECTusually stay a loop; really measuring it is 08-05. - And you can recognise when it isn't the tool: several repeated correlations call for a
LEFT JOINwithGROUP BY, and any ranking calls for window functions (10-03).
In the next lesson, EXISTS and NOT EXISTS, you'll see the purest form of correlated subquery: one that returns no value at all, it only answers yes or no. With it you'll finally write "customers who have never bought" without a LEFT JOIN, you'll discover why NOT EXISTS is safe with nulls and NOT IN isn't, and you'll solve the classic problem of relational division: which customers have bought from every category.
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
