The previous lesson ended with a list of questions you couldn't answer yet, and they all had the same shape: comparing each row with the result of another query. Which products are above the catalogue's average price? Which customers have an average order value above the overall average —the question 04-06 explicitly set aside for this lesson? Which orders include the most expensive product?
The answer to all of them is the same tool: a subquery, a query written in parentheses inside another one. Here you'll learn the vocabulary, the three types of subquery according to what they return, how they're used with IN, ANY and ALL, and the three classic mistakes that produce anything from a red message to —far worse— an empty result with no warning at all.
Contents
- What a subquery is: vocabulary, correlation and types
- Scalar subquery in the
WHERE - The question left over from 04-06: the average order value
- List subqueries:
IN,ANY/SOMEandALL - The three classic mistakes
- Where a subquery can appear
- Common Mistakes and Tips
- Exercises
- Conclusion
- What a subquery is and what each part is called
A subquery (or nested query) is a complete SELECT statement, written in parentheses, that appears inside another SQL statement. The engine runs it and uses its result as if it were a value, a list or a table.
SELECT id, name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products);
-- └──────── subquery ────────┘The query containing the other one is the outer query; the one inside, the subquery. A subquery can contain another, with no limit other than readability. Three syntax rules with no exceptions: the parentheses are mandatory; the subquery is written in full (SELECT, FROM, WHERE, GROUP BY… whatever it needs); and an ORDER BY inside a subquery is almost always useless, unless it comes with a LIMIT.
Non-correlated against correlated
This is the distinction that structures the whole module, and it's worth fixing before any other.
| Non-correlated | Correlated | |
|---|---|---|
| Reference to the outer query | No | Yes, it uses an alias from outside |
| Can it be run on its own? | Yes, by copying and pasting | No: it errors |
| How many times it's evaluated | Once for the whole query | Once for every candidate row |
| Conceptual cost | Constant | Proportional to the number of rows |
| Lesson | This one (07-01) | The next one (07-02) |
-- NON-CORRELATED: the subquery mentions nothing from outside
SELECT id, name FROM products AS p
WHERE p.price > (SELECT AVG(price) FROM products);
-- CORRELATED: the subquery uses p.category_id, which comes from outside
SELECT id, name FROM products AS p
WHERE p.price > (SELECT AVG(price) FROM products WHERE category_id = p.category_id);The practical test is infallible: select the subquery, run it on its own and see what happens. The first returns 9.0350000000000000. The second gives ERROR: missing FROM-clause entry for table "p", because p doesn't exist outside the outer query. This entire lesson is about the non-correlated ones.
The three types according to what they return
The second classification, cutting across the previous one, refers to the shape of the result, and it determines where the subquery can appear and which operators it combines with.
| Type | Returns | Example | Used with |
|---|---|---|---|
| Scalar | One row and one column: a value | (SELECT AVG(price) FROM products) |
=, >, <, >=, <=, <>; or as a column of the SELECT |
| Row | One row with several columns | (SELECT MAX(price), MIN(price) FROM products) |
Tuple comparison: (a, b) = (SELECT ...) |
| Table (multi-row) | Several rows | (SELECT customer_id FROM orders) |
IN, NOT IN, ANY, ALL, EXISTS, or in the FROM |
Scalar and table subqueries cover 99 % of the SQL you'll write. Row subqueries are elegant but rare: WHERE (price, stock) = (SELECT MAX(price), 40 FROM products) returns a single row, the matcha (€22.00 and 40 units).
Dialect note: row constructors
(a, b) = (...)work in PostgreSQL, MySQL and MariaDB; SQLite and SQL Server don't support them and require two conditions joined withAND.
- Scalar subquery in the
WHERE
WHEREThe most common use case of them all: comparing each row with a value computed over the whole set. The question is "which products are above the catalogue's average price?", and the naive attempt is this one:
ERROR: aggregate functions are not allowed in WHERE
LINE 1: ... name, price FROM products WHERE price > AVG(price);
^It's the same error as in 04-06 and for the same reason: when the WHERE runs (step 2 of the logical order) the engine looks at one row at a time and hasn't computed any aggregate. The subquery solves it because it's another query, with its own complete pass over the table:
-- ✅ CORRECT
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 AVG(price) FROM products)
ORDER BY p.price DESC;| id | product | category | price |
|---|---|---|---|
| 15 | Ceremonial matcha green tea 30 g | Drinks | 22.00 |
| 6 | Aloe vera face cream 50 ml | Natural cosmetics | 18.90 |
| 20 | Spirulina capsules 120 units | Supplements | 16.40 |
| 8 | Almond body oil 200 ml | Natural cosmetics | 14.25 |
| 13 | Soy wax candles (pack of 2) | Sustainable home | 13.75 |
| 1 | Extra virgin olive oil 500 ml | Food | 12.50 |
| 10 | Concentrated eco laundry detergent 1 L | Sustainable home | 11.20 |
| 12 | Reusable cotton bags (pack of 5) | Sustainable home | 9.90 |
| 3 | Raw orange blossom honey 500 g | Food | 9.75 |
9 rows: 9 products out of 20 beat the €9.035 average price (SELECT ROUND(AVG(price), 4) FROM products → 9.0350, the figure from 04-04). What matters is how it runs: PostgreSQL evaluates the subquery only once, gets 9.0350000000000000 and replaces the expression with that number; from there on the outer query is a plain WHERE price > 9.0350000000000000. There's no loop: it's a constant computed on the fly. And watch a detail that matters: the comparison is done with the unrounded value — with other data, a cent decides whether a row is in or out. Never round the comparison value; round only what you display.
- The question left over from 04-06: the average order value
In 04-06 you computed each customer's average order value and confirmed that the overall average of the 20 orders is €36.40, but you couldn't put the two things together: HAVING knows how to compare a group's aggregate with a constant or with another aggregate of the same group, never with an aggregate computed over a different set of rows. A scalar subquery in the HAVING is the piece that was missing.
First, the reference value. The overall average isn't the average of the 47 lines (that would give the €15.49 average line amount), but the average of the 20 orders:
SELECT COUNT(DISTINCT order_id) AS orders,
ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS revenue,
ROUND(SUM(quantity * unit_price * (1 - discount))
/ COUNT(DISTINCT order_id), 2) AS global_avg_order_value
FROM order_lines;| orders | revenue | global_avg_order_value |
|---|---|---|
| 20 | 727.95 | 36.40 |
And now the same expression, injected into the HAVING:
-- ✅ The query 04-06 left pending
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
c.country,
COUNT(DISTINCT o.id) AS orders,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
/ COUNT(DISTINCT o.id), 2) AS avg_order_value
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
JOIN customers AS c ON o.customer_id = c.id
GROUP BY c.id, c.name, c.last_name, c.country
HAVING SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
/ COUNT(DISTINCT o.id)
> (SELECT SUM(quantity * unit_price * (1 - discount))
/ COUNT(DISTINCT order_id)
FROM order_lines)
ORDER BY avg_order_value DESC;| id | customer | country | orders | total | avg_order_value |
|---|---|---|---|---|---|
| 10 | Julien Moreau | France | 1 | 66.90 | 66.90 |
| 7 | Sofia Moreira Costa | Portugal | 2 | 111.88 | 55.94 |
| 8 | Tiago Almeida Nunes | Portugal | 1 | 44.60 | 44.60 |
Three customers out of twelve, exactly the ones 04-06 anticipated. Step by step:
FROM+ twoJOINs: it starts from the 47 detail lines and climbs up to the customer; it's the course's canonical detail query. TheGROUP BYforms 12 groups, one per buying customer (customers 13, 14 and 15 were already discarded by theINNER JOIN).- The scalar subquery is evaluated once and returns
36.39725, the exact unrounded average. HAVINGcompares each group's average order value with that constant and discards nine groups.SELECTprojects and rounds. The rounding is presentation only: the comparison was already made at full precision.
And a business reading: all three are foreign customers, no Spaniard beats the average (the closest is Lucía, with €35.87). Shipping to Portugal and France costs €9.90 and €12.50 against the €4.95 domestic rate, and the customer compensates by placing bigger orders. That said, two of the three have a single order: the genuinely solid case is Sofia, with two orders of €64.88 and €47.00.
Why it goes in the
HAVINGand not in theWHERE: the condition compares a group aggregate with the constant, and theWHEREcan't use aggregates. The subquery only supplies the number to compare against.
- List subqueries:
IN, ANY/SOME and ALL
IN, ANY/SOME and ALLWhen the subquery returns several rows of a single column, the natural way to use it is IN. You already know the operator from 04-02 with a hand-written list; now the list is computed by another query.
-- ✅ Customers who have placed at least one order
SELECT c.id, c.name, c.last_name, c.city, c.country
FROM customers AS c
WHERE c.id IN (SELECT customer_id FROM orders)
ORDER BY c.id LIMIT 3;| id | name | last_name | city | country |
|---|---|---|---|---|
| 1 | Lucía | Martínez Soler | Valencia | Spain |
| 2 | Carlos | Ferrer Ibáñez | Valencia | Spain |
| 3 | Marta | Sanchis Gil | Castellón | Spain |
(First 3 of 12 rows: customers 1 to 12, the 12 buyers.) There's a capital detail here that we'll come back to in 07-05: the subquery returns 20 values (one per order, with repetitions), but the result has 12 rows. IN doesn't multiply: it asks whether the value is in the list and answers yes or no exactly once per outer row. An INNER JOIN with orders would have returned 20 rows.
A second example, with an aggregate subquery: products from the categories that have more than three products.
SELECT COUNT(*) AS products
FROM products AS p
WHERE p.category_id IN (SELECT category_id FROM products
GROUP BY category_id HAVING COUNT(*) > 3);| products |
|---|
| 17 |
17 products out of 20. The categories with more than three references are Food (5), Natural cosmetics (4), Sustainable home (4) and Drinks (4); left out are Personal hygiene (products 18 and 19) and Supplements (number 20). The subquery is a complete aggregate query, with its GROUP BY and its HAVING: you can compute a set of keys with the whole machinery of module 4 and use it as a filter.
The equivalence with = ANY. The standard defines IN as syntactic sugar for = ANY: c.id IN (sub) and c.id = ANY (sub) produce the same plan and the same result. IN reads better and is what you'll see in real code; = ANY matters because it explains the whole family:
| Form | True when… | Equivalent to |
|---|---|---|
x = ANY (sub) |
x matches at least one |
x IN (sub) |
x <> ALL (sub) |
x differs from all of them |
x NOT IN (sub) |
x > ANY (sub) |
x beats at least one: it beats the minimum |
x > (SELECT MIN(...) ...) |
x > ALL (sub) |
x beats them all: it beats the maximum |
x > (SELECT MAX(...) ...) |
x < ANY (sub) |
x is lower than the maximum |
x < (SELECT MAX(...) ...) |
x < ALL (sub) |
x is lower than the minimum |
x < (SELECT MIN(...) ...) |
SOME is an exact synonym of ANY that nobody uses. The form that really shows up is > ALL, and its natural question is "more expensive than any of the ones in that category":
SELECT p.id, p.name AS product, p.price
FROM products AS p
WHERE p.price > ALL (SELECT price FROM products WHERE category_id = 1)
ORDER BY p.price DESC;| id | product | price |
|---|---|---|
| 15 | Ceremonial matcha green tea 30 g | 22.00 |
| 6 | Aloe vera face cream 50 ml | 18.90 |
| 20 | Spirulina capsules 120 units | 16.40 |
| 8 | Almond body oil 200 ml | 14.25 |
| 13 | Soy wax candles (pack of 2) | 13.75 |
5 products cost more than all the Food ones, whose ceiling is the olive oil (€12.50). Writing > (SELECT MAX(price) FROM products WHERE category_id = 1) returns the same thing and reads better. With ANY instead of ALL the condition would be "more expensive than the cheapest Food product" (€1.95) and 19 products would come out.
Careful with empty sets. If the subquery returns no rows,
> ALLis true for every row (there's no counterexample) and> ANYis false for every row (there's no favourable case). Flawless as logic and baffling in practice:price > ALL (SELECT price FROM products WHERE category_id = 99)returns all 20 products.
- The three classic mistakes
5.1. The scalar that returns more than one row
-- ⚠️ INCORRECT
SELECT id, name, price
FROM products
WHERE price > (SELECT price FROM products WHERE category_id = 1);Food has five products, the subquery returns five prices and > doesn't know which one to compare against. There are three fixes and each one answers a different question: > (SELECT MAX(price) ...) and > ALL (...) say "more expensive than the most expensive one"; > ANY (...) says "more expensive than at least one". It's the least dangerous of the three because it's noisy, and it's a data error, not a syntax one: the same query would work if the category had a single product and would blow up the moment the second one was added. If the subquery returns several columns, the message is ERROR: subquery must return only one column.
5.2. The scalar that returns zero rows — the dangerous one
-- ⚠️ Returns 0 rows, and there's no error at all
SELECT id, name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products WHERE category_id = 99);No message, no warning. Category 99 doesn't exist, the subquery finds no rows, AVG over an empty set returns NULL (04-04) and price > NULL evaluates to UNKNOWN for all twenty rows. Since the WHERE only lets through what's TRUE (04-03), the result empties out silently. This is the most dangerous of the three, because an empty report looks like a legitimate report: "there weren't any this month". How to defend yourself: always run the subquery on its own before nesting it; wrap it in COALESCE when a sensible default exists (> COALESCE((SELECT AVG(...)...), 0), 06-04); and if the question is about existence, use EXISTS, which never returns NULL (07-03).
5.3. NOT IN with NULL — meeting 04-02 again
In 04-02 you called it "the most expensive mistake in SQL". With subqueries it's far easier to make, because you no longer see the list: another query computes it and you don't know whether it brings nulls along.
-- ⚠️ INCORRECT: returns 0 rows
SELECT id, name, last_name, job_title
FROM employees
WHERE id NOT IN (SELECT employee_id FROM orders);And yet you know there are five employees with no orders: only 4, 5 and 6 appear in orders. What's happened is that the subquery returns {4, 5, 6, NULL} — the 10 web orders have employee_id set to NULL. For employee 1:
1 NOT IN (4, 5, 6, NULL) ≡ 1 <> 4 AND 1 <> 5 AND 1 <> 6 AND 1 <> NULL ≡ TRUE AND TRUE AND TRUE AND UNKNOWN ≡ UNKNOWN → not TRUE → the row is discarded
The same for all eight rows. With a single NULL in the list, NOT IN can never return TRUE. The three fixes —filtering the nulls inside, NOT EXISTS (07-03) and 03-03's anti-join— are these:
WHERE id NOT IN (SELECT employee_id FROM orders WHERE employee_id IS NOT NULL) -- ✅ 1
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.employee_id = employees.id) -- ✅ 2
FROM employees AS e LEFT JOIN orders AS o ON o.employee_id = e.id
WHERE o.id IS NULL -- ✅ 3All three return the same thing:
| id | name | last_name | job_title |
|---|---|---|---|
| 1 | Rosa | Alcázar Vives | General manager |
| 2 | Andrés | Company Talens | Sales manager |
| 3 | Beatriz | Nadal Ripoll | Logistics manager |
| 7 | Irene | Salvador Mira | Warehouse operator |
| 8 | Daniel | Vercher Lluch | Data analyst |
5 rows. And there's an asymmetry that surprises everybody: IN does work with nulls. 4 IN (4, 5, 6, NULL) is TRUE, because it's enough for one comparison to hit. The problem is exclusive to the negation. The comparison of the four ways of answering "what doesn't match" arrives in 07-03, and its definitive table in 07-05.
- Where a subquery can appear
Almost anywhere a value or a table fits:
| Place | Which type | What for | Lesson |
|---|---|---|---|
SELECT |
Scalar | Column computed from another table | 07-04 |
FROM / JOIN ... ON |
Table | Derived table: aggregate and aggregate again, or join against an aggregate | 07-04 |
WHERE |
Any | Filter with a computed value or list | This one |
HAVING |
Scalar | Compare a group's aggregate with a global one | This one, section 3 |
INSERT ... SELECT |
Table | Insert the result of a query | 05-02 |
UPDATE ... SET |
Scalar | Compute the new value from another table | 05-03 |
UPDATE/DELETE ... WHERE |
Any | Choose which rows get modified or deleted | 05-03, 05-04 |
Two examples from module 5 revisited, now that you know what they're called: UPDATE products SET price = ROUND(price * 1.05, 2) WHERE id NOT IN (SELECT product_id FROM order_lines) raises by 5 % whatever has never been sold, and DELETE FROM reviews WHERE customer_id NOT IN (SELECT id FROM customers) cleans up orphan reviews. Both are safe because order_lines.product_id and customers.id are NOT NULL. If either allowed nulls, the UPDATE would modify nothing and the DELETE would delete nothing — 5.3's trap, now with consequences for the data. And where a subquery can't appear: in the GROUP BY, nor —in PostgreSQL— inside a CHECK constraint.
Common Mistakes and Tips
- Writing an aggregate in the
WHERE.aggregate functions are not allowed in WHERE. What you need is a scalar subquery. - Using a multi-row subquery where a value is expected.
more than one row returned by a subquery used as an expression. Add an aggregate, anORDER BY ... LIMIT 1, or switch toIN/ANY/ALL. - Not checking that the scalar returns something. If it returns zero rows it's worth
NULL, the filter empties out silently and the report looks correct. It's the most expensive mistake in the lesson. NOT INover a nullable column. Zero rows, always. UseNOT EXISTSor filter the nulls inside the subquery.- Rounding the comparison value.
> ROUND(AVG(price), 2)isn't> AVG(price). Round when displaying, not when comparing. And avoid theORDER BYinside a list subquery: it adds nothing and costs time. - Tip: always run the subquery on its own first. It's the module's number-one debugging technique; if it can't run on its own, it's correlated.
- Tip: if you need columns from the other table in the result, it isn't a subquery: it's a
JOIN. The subquery filters and computes; it contributes no columns to the outerSELECT(07-05).
Exercises
Exercise 1
Marketing wants a list of expensive products measured against a specific yardstick: the ones above the average price of the Natural cosmetics category. Show id, name, category and price, sorted by price descending, and don't write the threshold by hand. Then answer: how many of the products that come out are from Natural cosmetics, and why don't the other two from that category appear?
Exercise 2
A colleague wants to know which customers have never written a review and has written this:
-- ⚠️ Suspicious
SELECT id, name, last_name FROM customers
WHERE id NOT IN (SELECT customer_id FROM reviews);- Does it work? Justify your answer by looking at 01-06's schema, without running it.
- Give the result and say how many of those customers have bought at some point.
- Rewrite it with
NOT EXISTSand with an anti-join, and explain why all three are equivalent here.
Exercise 3
Management asks: "which orders include the catalogue's most expensive product?". Write a single query returning the order's id, date, customer and status, without writing either the name or the id of that product by hand. (Hint: you'll need two subqueries, one inside the other.) Then say what would happen if two products were tied at the maximum price.
Solutions
Solution 1
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 AVG(price) FROM products WHERE category_id = 2)
ORDER BY p.price DESC;| id | product | category | price |
|---|---|---|---|
| 15 | Ceremonial matcha green tea 30 g | Drinks | 22.00 |
| 6 | Aloe vera face cream 50 ml | Natural cosmetics | 18.90 |
| 20 | Spirulina capsules 120 units | Supplements | 16.40 |
| 8 | Almond body oil 200 ml | Natural cosmetics | 14.25 |
| 13 | Soy wax candles (pack of 2) | Sustainable home | 13.75 |
| 1 | Extra virgin olive oil 500 ml | Food | 12.50 |
6 rows. The computed threshold is €11.5375 (€46.15 across 4 products). Only two are from Natural cosmetics, the aloe cream and the body oil; the other two in the category —shampoo €8.40 and lip balm €4.60— fall below their own average, which is pulled upwards precisely by the two expensive ones.
The underlying point: the threshold comes from one category but is applied to the whole catalogue, because a non-correlated subquery is evaluated once and holds equally for all twenty rows. Comparing each product with the average of its own category demands a correlated one: next lesson.
Solution 2
1. Yes it works, and you can tell without running it: reviews.customer_id is declared NOT NULL (01-06, table 3.8), so the subquery can't return any NULL and NOT IN behaves correctly. The suspicion was the right reflex in the face of any NOT IN; the schema clears it up. 2. The result:
| id | name | last_name |
|---|---|---|
| 5 | Ana | Belmonte Roca |
| 10 | Julien | Moreau |
| 12 | Diego | Ramos Herrera |
| 13 | Núria | Bosch Ferrer |
| 14 | Hugo | Iglesias Pardo |
| 15 | Inés | Carrasco Vega |
6 customers out of 15, and three of them have bought: Ana (2 orders), Julien (1) and Diego (1). The other three are the familiar 13, 14 and 15, who have never bought and therefore couldn't review anything. Telling the two groups apart matters: Ana, Julien and Diego can be asked for an opinion; Núria, Hugo and Inés have to be sold something first.
3. The two rewrites, with the same six-row result:
SELECT c.id, c.name, c.last_name FROM customers AS c -- NOT EXISTS (07-03)
WHERE NOT EXISTS (SELECT 1 FROM reviews AS r WHERE r.customer_id = c.id) ORDER BY c.id;
SELECT c.id, c.name, c.last_name FROM customers AS c -- anti-join (03-03)
LEFT JOIN reviews AS r ON r.customer_id = c.id WHERE r.id IS NULL ORDER BY c.id;All three are equivalent here for a single reason: reviews.customer_id is NOT NULL. If anonymous reviews with a null customer_id were allowed tomorrow, the NOT IN version would start returning zero rows and the other two would carry on working. The equivalence depends on the schema, not on the syntax.
Solution 3
SELECT o.id AS order_id,
o.order_date,
c.name || ' ' || c.last_name AS customer,
o.status
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.id
WHERE o.id IN (SELECT ol.order_id FROM order_lines AS ol
WHERE ol.product_id = (SELECT id FROM products
ORDER BY price DESC LIMIT 1))
ORDER BY o.id;| order_id | order_date | customer | status |
|---|---|---|---|
| 4 | 2025-04-19 | Javier Ortega Ruiz | delivered |
| 12 | 2025-10-01 | Julien Moreau | delivered |
| 17 | 2026-01-13 | Sofia Moreira Costa | shipped |
3 orders. The most expensive product is the Ceremonial matcha green tea 30 g (€22.00) and it has been sold three times. There are two levels: the inner subquery returns a scalar value (the id of the most expensive product) and the middle one, a list of order_id.
If there were a tie, the inner one would still return a single row —LIMIT 1 cuts arbitrarily— and you'd silently lose the orders for the other product. The robust version swaps the = for an IN and drops the LIMIT: WHERE ol.product_id IN (SELECT id FROM products WHERE price = (SELECT MAX(price) FROM products)). With today's data it returns the same thing, but it doesn't break the day somebody adds a second product at €22.00. ORDER BY ... LIMIT 1 inside a subquery is convenient and fragile.
Conclusion
You've opened the module's door:
- A subquery is a query in parentheses inside another one; the one containing it is the outer query. According to what they return there are three types —scalar, row and table— and each admits different operators.
- The distinction that organises the module is non-correlated (mentions nothing from outside, runs on its own, is evaluated once) against correlated (uses an outer alias, can't be run in isolation, is evaluated once per row).
- A scalar in the
WHEREsolves "above the average": 9 of the 20 products beat the catalogue's €9.035. And a scalar in theHAVINGcloses the question 04-06 left pending: Julien, Sofia and Tiago are the only ones with an average order value above the €36.40 overall average. IN (SELECT ...)filters by a computed list —12 buying customers, 17 products from categories with more than three references— and is equivalent to= ANY;> ALLcompares against the maximum and> ANYagainst the minimum. And you know the three classic mistakes: the scalar with several rows (noisy), the scalar with zero rows (silent: it givesNULLand empties the result) andNOT INwith nulls (zero rows guaranteed).
All the subqueries in this lesson have something in common: they're computed once and are worth the same for every row. That's why none of them could answer "which products beat the average of their category": that threshold is different for each row. In the next lesson, correlated subqueries, the subquery will start looking outwards —at the row the outer query is examining at that moment— and will run once for each of them. The mental model changes, the cost changes and a whole family of new questions appears.
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
