SQL (PostgreSQL, MySQL, SQLite) Module 6 – Subqueries and set operations
Correlated subqueries in SQL
Write subqueries that read the outer row, find the latest order of each customer with ties handled, and know when a join or window function is faster.
What you will learn
- Write subqueries that refer to the outer row
- Solve group-wise maximum problems with a correlated subquery
- Rewrite correlated subqueries as joins or window functions when that is faster
- Predict how the cost of a correlated subquery grows, and which index it needs
Before you start
What you’ll learn · Before you start
What you will learn
- Write subqueries that refer to the outer row
- Solve group-wise maximum problems with a correlated subquery
- Rewrite correlated subqueries as joins or window functions when that is faster
- Predict how the cost of a correlated subquery grows, and which index it needs
Before you start
On this page
A correlated subquery is a subquery that refers to a column of the outer query, such as q.category = p.category, where p is the outer table. Because that value changes from row to row, the subquery is, logically,
evaluated again for every outer row, with that row’s values filled in. That makes it the natural way to compare each
row with its own group: products dearer than the average of their own category, each customer’s latest order, the
students whose best offer beats last year’s. It reads almost like the question in plain words. The price is speed:
run once per row, the subquery needs an index on the column it filters by, or a rewrite as a join or a window
function, both of which this lesson shows.
All of the examples run in the page itself, with sql.js 1.14.2 running SQLite 3.49.1 inside the browser tab; statements about PostgreSQL 18 and MySQL 8.4 rest on their manuals.
A subquery that reads the outer row
-- Nine products of a grocery shop, prices in paise. Spices are cheap, so they rarely beat the shop's average.
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
price_paise INTEGER NOT NULL
);
INSERT INTO products VALUES
(1, 'Masala tea 250 g', 'drinks', 18000), (2, 'Filter coffee 500 g', 'drinks', 42000),
(3, 'Green tea 100 g', 'drinks', 24000), (4, 'Basmati rice 5 kg', 'grains', 65000),
(5, 'Wheat atta 10 kg', 'grains', 48000), (6, 'Red lentils 1 kg', 'grains', 14000),
(7, 'Turmeric 100 g', 'spices', 4000), (8, 'Chilli powder 200 g', 'spices', 6000),
(9, 'Cumin seeds 100 g', 'spices', 11000);
-- 1. Uncorrelated: one average for the whole shop, computed once.
SELECT name, category, price_paise / 100.0 AS rupees
FROM products
WHERE price_paise > (SELECT avg(price_paise) FROM products)
ORDER BY category, name;
-- 2. Correlated: p.category inside the subquery is the outer row's category, so each product meets the average of
-- its own category.
SELECT p.name, p.category, p.price_paise / 100.0 AS rupees,
round((SELECT avg(q.price_paise) FROM products AS q WHERE q.category = p.category) / 100.0, 2) AS category_average
FROM products AS p
WHERE p.price_paise > (SELECT avg(q.price_paise) FROM products AS q WHERE q.category = p.category)
ORDER BY p.category, p.name; Output
┌─────────────────────┬──────────┬────────┐ │ name │ category │ rupees │ ├─────────────────────┼──────────┼────────┤ │ Filter coffee 500 g │ drinks │ 420.0 │ │ Basmati rice 5 kg │ grains │ 650.0 │ │ Wheat atta 10 kg │ grains │ 480.0 │ └─────────────────────┴──────────┴────────┘ ┌─────────────────────┬──────────┬────────┬──────────────────┐ │ name │ category │ rupees │ category_average │ ├─────────────────────┼──────────┼────────┼──────────────────┤ │ Filter coffee 500 g │ drinks │ 420.0 │ 280.0 │ │ Basmati rice 5 kg │ grains │ 650.0 │ 423.33 │ │ Wheat atta 10 kg │ grains │ 480.0 │ 423.33 │ │ Cumin seeds 100 g │ spices │ 110.0 │ 70.0 │ └─────────────────────┴──────────┴────────┴──────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < own_average.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
A correlated subquery runs again for each outer row
Text description of the diagram
The diagram follows the lesson's correlated query through two rows of the outer query, from top to bottom.
- The outer row is Cumin seeds 100 g, category spices, 110 rupees.
- Its category goes into the subquery, which computes the average price of the spices: 70 rupees.
- 110 is greater than 70, so the row is kept.
- The next outer row is Green tea 100 g, category drinks, 240 rupees.
- The subquery runs again, now with the category drinks, and computes their average: 280 rupees.
- 240 is not greater than 280, so the row is left out.
The first query compares every product with one number, the shop’s average of 257.78 rupees, and no spice comes
close. The second gives each product its own yardstick. Its subquery mentions p.category, a column of the outer
row, so for cumin seeds it computes the average of the spices, 70 rupees, and for green tea the average of the
drinks, 280 rupees. Cumin seeds at 110 rupees now pass, while green tea at 240 does not. SQLite’s manual describes
the rule: a correlated subquery is re-evaluated each time its result is required, while an uncorrelated one is
evaluated once and reused. PostgreSQL’s manual says the outer values act as constants during any one evaluation.
Give the outer and inner tables different aliases, p and q here. When the same name could mean either, the
engine resolves it to the nearest query, the subquery itself; MySQL’s manual calls this evaluating from inside to
outside. A missing alias can silently turn the correlation into category = category inside the subquery, which
compares each inner row with itself and is true for every row that has a category.
The latest order of each customer
“The latest row per group” is the classic job for a correlated subquery:
-- Orders of four customers. Bilal placed two orders on the same day.
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
placed_on TEXT NOT NULL,
amount_paise INTEGER NOT NULL
);
INSERT INTO orders VALUES
(1, 'Asha', '2026-03-02', 50000), (2, 'Asha', '2026-03-09', 70000), (3, 'Bilal', '2026-03-05', 30000),
(4, 'Bilal', '2026-03-05', 45000), (5, 'Chitra', '2026-03-01', 90000), (6, 'Chitra', '2026-02-20', 20000),
(7, 'Dev', '2026-03-11', 25000);
-- The latest order of each customer: keep an order whose date is the latest date of the same customer.
SELECT o.id, o.customer, o.placed_on, o.amount_paise / 100.0 AS rupees
FROM orders AS o
WHERE o.placed_on = (SELECT max(o2.placed_on) FROM orders AS o2 WHERE o2.customer = o.customer)
ORDER BY o.customer, o.id; Output
┌────┬──────────┬────────────┬────────┐ │ id │ customer │ placed_on │ rupees │ ├────┼──────────┼────────────┼────────┤ │ 2 │ Asha │ 2026-03-09 │ 700.0 │ │ 3 │ Bilal │ 2026-03-05 │ 300.0 │ │ 4 │ Bilal │ 2026-03-05 │ 450.0 │ │ 5 │ Chitra │ 2026-03-01 │ 900.0 │ │ 7 │ Dev │ 2026-03-11 │ 250.0 │ └────┴──────────┴────────────┴────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < latest_order.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
For each order, the subquery finds the latest date of the same customer, and the order is kept when its date equals it. Asha, Chitra and Dev get one order each. Bilal gets two, because both of his orders were placed on the latest day. Whether that is right depends on the question: “every order placed on the customer’s last day” wants both, “one row per customer” needs a rule that breaks the tie.
Three ways to ask a group-wise question
| Criterion | Correlated subquery | Join to a grouped derived table | Window function |
|---|---|---|---|
| Reads as | The question, row by row | Two steps: group, then join | A ranking inside each group |
| Ties on the latest date | Kept | Kept | Kept with rank(), one row with row_number() |
| Work | A subquery per row; fast with an index | One grouping pass | One sorted pass |
| Engines | All three | All three | PostgreSQL, MySQL 8.0 and later, SQLite 3.25 and later |
All three return the same rows on clean data, and they differ in how they break ties, how missing values behave and how much work they cost:
- A tie-breaker makes the correlated form return exactly one row per group.
- A NULL in the date makes the forms disagree about the latest row.
- Without an index a correlated subquery rereads the table once per outer row, which grows with the square of the table.
- A window function ranks the rows of each group in one pass.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Your turn
Exercise · Medium · SQL
Show each student's best job offer, ties included
A college records the job offers its final-year students receive in offers, with the columns id, student, company and salary_rupees (the yearly salary offered).
Write a query that returns, for every student, the offer or offers with that student's highest salary, with the columns student, company and salary_rupees. When two offers of the same student share the highest salary, return both. The order of the rows does not matter.
The starter query groups by student. Run the sample tests to see which case it gets wrong.
Starter code · best_offer.sql
-- For every student, the offer(s) with the highest salary: student, company, salary_rupees.
SELECT student, company, max(salary_rupees) AS salary_rupees
FROM offers
GROUP BY student; The sample tests · tests.yaml
# Sample tests: each one builds a small offers table, runs your query on it and compares the rows it returns (in any
# order).
tests:
- name: picks each student's highest offer
setup: |
CREATE TABLE offers (id INTEGER PRIMARY KEY, student TEXT NOT NULL, company TEXT NOT NULL, salary_rupees INTEGER NOT NULL);
INSERT INTO offers VALUES
(1, 'Ananya', 'Banyan Analytics', 900000), (2, 'Ananya', 'Bluefin Data', 1200000),
(3, 'Rohan', 'Kite Systems', 650000), (4, 'Meher', 'Bluefin Data', 800000), (5, 'Meher', 'Orbit Retail', 700000);
expected:
columns: [student, company, salary_rupees]
rows:
- ['Ananya', 'Bluefin Data', 1200000]
- ['Rohan', 'Kite Systems', 650000]
- ['Meher', 'Bluefin Data', 800000]
- name: keeps both offers when two share the highest salary
setup: |
CREATE TABLE offers (id INTEGER PRIMARY KEY, student TEXT NOT NULL, company TEXT NOT NULL, salary_rupees INTEGER NOT NULL);
INSERT INTO offers VALUES
(1, 'Ishaan', 'Kite Systems', 1000000), (2, 'Ishaan', 'Orbit Retail', 1000000), (3, 'Ishaan', 'Banyan Analytics', 750000),
(4, 'Zara', 'Bluefin Data', 900000);
expected:
columns: [student, company, salary_rupees]
rows:
- ['Ishaan', 'Kite Systems', 1000000]
- ['Ishaan', 'Orbit Retail', 1000000]
- ['Zara', 'Bluefin Data', 900000]
- name: compares each student only with their own offers
setup: |
CREATE TABLE offers (id INTEGER PRIMARY KEY, student TEXT NOT NULL, company TEXT NOT NULL, salary_rupees INTEGER NOT NULL);
INSERT INTO offers VALUES
(1, 'Kavya', 'Orbit Retail', 500000), (2, 'Kavya', 'Kite Systems', 450000), (3, 'Vikram', 'Banyan Analytics', 1500000);
expected:
columns: [student, company, salary_rupees]
rows:
- ['Kavya', 'Orbit Retail', 500000]
- ['Vikram', 'Banyan Analytics', 1500000] A hint
Keep an offer when its salary equals the highest salary of the same student. The highest salary is a subquery that refers to the outer row: (SELECT max(o2.salary_rupees) FROM offers AS o2 WHERE o2.student = o.student), with the outer table named o.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The latest row per group four ways, checked against each other Pro: opens with a Pro, Premium or Ultimate pass
Four common ways to write “the latest order per customer” agree on clean data and disagree as soon as there is a tie or a missing date. This part runs all four on the same orders, shows which rows each returns and why, and turns the differences into a choice.
In this part:
- Ties and a missing date, reproduced on eight orders
- Breaking ties on purpose
- Choosing among the four: the trade-offs
1 runnable example (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
How often a correlated subquery runs, measured on 3,000 orders Pro: opens with a Pro, Premium or Ultimate pass
A correlated subquery is cheap when each run is a short index search and expensive when each run is a full scan. This part times both on 3,000 orders, reads the plans, and scales the numbers up to a production table.
In this part:
- 9 million row visits against one grouped pass, timed
- What the plans say in PostgreSQL and MySQL
- Worked numbers at production size
1 runnable example (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Rewriting correlated subqueries as joins, window functions and updates Pro: opens with a Pro, Premium or Ultimate pass
Most correlated subqueries can be written another way, and sometimes they should be. This part rewrites three common uses, shows the trap in the most common rewrite, and says when the correlated form is the better choice.
In this part:
- Counting per row: the LEFT JOIN rewrite and its count(*) trap
- Window functions: rank() for ties, row_number() for exactly one
- A correlated subquery in UPDATE
- Trade-offs: when the correlated form is the better choice
1 runnable example (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid
Return each customer's latest order from orders(id, customer_id, placed_at, amount). Two orders can share a timestamp. Write the query, then change it so that exactly one row per customer comes back, and say which row you return and why.
-
Fresher to mid
Two versions of a customer report count orders: one with a correlated subquery in the SELECT list, the other with LEFT JOIN orders and COUNT(*). They agree for most customers but not for all. Which customers differ, which version is right, and why?
Key takeaways
- A correlated subquery refers to the outer row, and logically runs again for every outer row with that row’s values.
- It answers group-wise questions directly, such as each row against its own category, or the latest row of each group, where ties return several rows.
- Its cost grows with the number of outer rows times the work of each run, so it needs an index on the column it correlates on, or a rewrite as a join or a window function.
- Give the outer and inner tables different aliases, so that the correlation means what you intend.
References
- SQLite: SQL Language Expressions (correlated subqueries) (SQLite)
- SQLite: EXPLAIN QUERY PLAN (SQLite)
- PostgreSQL 18: Value Expressions (scalar subqueries) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Using EXPLAIN (subplans) (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: Correlated Subqueries (Oracle Corporation)
- MySQL 8.4 Reference Manual: EXPLAIN Output Format (DEPENDENT SUBQUERY) (Oracle Corporation)
- MySQL 8.4 Reference Manual: Switchable Optimizations (subquery_to_derived) (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress