AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

08 Subqueries / Lesson 3 of 5

Correlated subqueries — a subquery per row

16 min · +10 XP · 2 exercises · 4 questions

Read the text16 min
Exercises0/2
Quiz4 q

Quick answer

What is a correlated subquery?

A correlated subquery is a subquery that refers to a column of the outer query, so its result depends on the current outer row and it is logically evaluated once per row. For example, WHERE e.salary > (SELECT AVG(x.salary) FROM employees x WHERE x.dept = e.dept) compares each employee with the average of their own department rather than the whole company.

The subqueries so far were independent: (SELECT AVG(salary) FROM employees) gives the same answer whichever row is asking. Many questions need a different comparison for each row. Is this employee paid above their department's average? Is this order its customer's most recent? Does this product cost more than the average of its category? The inner query must know which outer row it is answering for, so it refers to the outer row's columns. That makes it a correlated subquery. This lesson covers how to write and read them, the per-group patterns they make easy, their use in the select list, how they perform, and a scope rule that produces some of the most baffling bugs in SQL.

The data

CREATE TABLE employees (id INT PRIMARY KEY, name VARCHAR(40), dept VARCHAR(20), salary INT);
INSERT INTO employees VALUES
  (1, 'Asha', 'Eng',   120000),
  (2, 'Ben',  'Eng',    95000),
  (3, 'Chen', 'Sales',  70000),
  (4, 'Dina', 'Sales',  82000),
  (5, 'Esha', 'Ops',    60000);

Department averages: Eng 107500, Sales 76000, Ops 60000.

Above their own department's average

SELECT e.name, e.dept, e.salary
FROM employees e
WHERE e.salary > (
  SELECT AVG(x.salary)
  FROM employees x
  WHERE x.dept = e.dept
)
ORDER BY e.dept, e.salary DESC, e.name;
A correlated subquery runs once per outer row
WHERE e.salary > (SELECT AVG(x.salary) FROM employees x WHERE x.dept = e.dept)

The inner query mentions e.dept, a column of the outer row, so it cannot run once on its own. Conceptually it runs again for every employee, with that employee's department filled in.

0:01 / 0:17Step 1 of 6
Transcript · 6 steps

The inner query mentions e.dept, a column of the outer row. Conceptually the database does this for every employee: take their department, compute that department's average, compare. For Asha it computes the Eng average (107500); for Chen the Sales average (76000). Esha is alone in Ops, equal to her own average, so > excludes her.

Two aliases of the same table appear — e outside and x inside — exactly as in a self join. They must be different, and every column should be qualified so it is obvious which copy it comes from.

The latest row per group

SELECT o.customer_id, o.id AS order_id, o.order_date, o.amount
FROM orders o
WHERE o.order_date = (
  SELECT MAX(x.order_date)
  FROM orders x
  WHERE x.customer_id = o.customer_id
)
ORDER BY o.customer_id, o.id;

For each order, the subquery finds the latest date among that customer's orders; the order is kept if it is dated on that day. This "greatest-n-per-group" pattern works for any extreme: the most expensive product per category, the first login per user, the highest score per student. Two properties to state in an interview:

  • ties are kept — a customer with two orders on the latest date gets both rows. If exactly one is wanted, add a tie-breaker (AND o.id = (SELECT MAX(x.id) … )) or use ROW_NUMBER() from the window-functions module;
  • customers with no orders do not appear, because there is no order row to start from.

Correlated subqueries in the select list

A correlated scalar subquery can compute a per-row value:

SELECT c.name,
       (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS orders,
       (SELECT MAX(o.order_date) FROM orders o WHERE o.customer_id = c.id) AS last_order
FROM customers c
ORDER BY c.name;

Customers with no orders get 0 and NULL — COUNT over no rows is 0, MAX is NULL — so this is an alternative to a left join with GROUP BY, and it never suffers from fan-out because each subquery aggregates its own table. The cost is that each subquery is a separate lookup per customer; for a handful of per-row figures over an indexed column that is fine, for a dozen it is better to aggregate once in a derived table (next lesson).

EXISTS is usually correlated

-- departments where someone earns more than the department's head
SELECT d.name
FROM departments d
WHERE EXISTS (
  SELECT 1 FROM employees e
  WHERE e.dept_id = d.id AND e.salary > d.head_salary
);

An uncorrelated EXISTS would be true for all rows or none, which is rarely what anyone means. When you see EXISTS, look for the outer column inside it.

How it actually runs

"Once per outer row" is the logical model, not necessarily the plan. MySQL 8:

  • converts IN/EXISTS subqueries into semi joins and NOT EXISTS into antijoins;
  • can turn some scalar subqueries into joins with a derived table (the subquery_to_derived optimizer switch, off by default);
  • otherwise runs the subquery as a DEPENDENT SUBQUERY, once per outer row.

What makes the per-row lookups cheap is an index on the correlated column — orders(customer_id) or orders(customer_id, order_date) for the latest-order query. Without one, each evaluation scans the table, and a 100,000-row outer table means 100,000 scans. If EXPLAIN shows DEPENDENT SUBQUERY on a large table with no usable index, rewrite as a join with a derived table of per-group aggregates.

The scope trap

Name resolution inside a subquery looks in the subquery's own tables first, then in the outer query's. That rule makes correlation possible — and it means a typo can silently correlate:

-- intended: customers who have a row in the `vip` table
SELECT name FROM customers
WHERE id IN (SELECT id FROM vip);   -- but vip's column is called customer_id

vip has no column id, so the inner id resolves to customers.id. The subquery becomes "for each vip row, the outer customer's id", and id IN (id, id, …) is true for every customer whenever vip is non-empty. No error, every row returned. Qualifying the inner column (SELECT v.id FROM vip v) turns the mistake into the error it should be. Qualify every column inside a subquery — this is the single best habit for avoiding subquery bugs.

Correlated subquery or join?

Both can express most per-group comparisons. Guidelines:

  • a per-row condition against an aggregate of related rows (above the department average, latest per customer) reads naturally as a correlated subquery;
  • when the outer query also needs several columns from the related rows, a join is usually clearer;
  • when the related table is large and the outer one too, aggregate once (derived table) and join.

Pitfalls

  • Reusing the same alias inside and outside, so the "correlation" refers to the inner table itself.
  • Unqualified inner columns silently binding to the outer query.
  • Forgetting that latest-per-group returns ties.
  • A correlated aggregate over a large table with no index on the correlated column.
  • Expecting groups with no rows to appear when the outer query starts from the child table.

Key takeaways

  • A correlated subquery refers to an outer column and is logically evaluated per outer row.
  • It is the natural way to compare a row with an aggregate of its own group.
  • col = (SELECT MAX(col) … WHERE same group) returns the latest row per group, ties included.
  • Correlated scalar subqueries in the select list compute per-row counts without fan-out.
  • Index the correlated column, and qualify every inner column to avoid accidental correlation.

Common questions

What is the difference between a correlated and a non-correlated subquery?

A non-correlated subquery does not mention the outer query, so it returns the same result for every row and runs once. A correlated subquery uses an outer column, such as e.dept, so its result changes from row to row and it is evaluated per outer row, although the optimizer may rewrite it into a join.

Are correlated subqueries slow?

They can be, because the subquery may run once per outer row. With an index on the correlated column, such as employees(dept), each run is a quick index lookup, and MySQL 8 often decorrelates EXISTS and IN into semi joins. A correlated aggregate over a large table is the case to rewrite as a join with a derived table.

How do I get each customer's latest order in SQL?

Keep the orders whose date equals the maximum date of that customer's orders: WHERE o.order_date = (SELECT MAX(x.order_date) FROM orders x WHERE x.customer_id = o.customer_id). Customers with two orders on the latest date get both rows. Window functions such as ROW_NUMBER are the other common answer.

Why does my subquery work even though the column does not exist in its table?

An unqualified column that the inner table lacks is resolved from the outer query, so WHERE id IN (SELECT id FROM orders) may silently compare the outer id with itself when orders has no id column. Qualify every column inside a subquery with its alias to make this mistake an error instead.

Mark as readSign in to keep your place.

SQL Correlated Subquery Explained: Per-Group Comparisons

A correlated subquery refers to the outer row and is evaluated for each one: salary above the department average, each customer's latest order, and scope traps.

  • Course: SQL study plan
  • Module: Subqueries
  • Kind: Lesson
  • Reading time: 16 min
  • Runtime: MySQL 8 syntax on SQLite 3.49

What is a correlated subquery?

A correlated subquery is a subquery that refers to a column of the outer query, so its result depends on the current outer row and it is logically evaluated once per row. For example, WHERE e.salary > (SELECT AVG(x.salary) FROM employees x WHERE x.dept = e.dept) compares each employee with the average of their own department rather than the whole company.

Lesson

The subqueries so far were independent: (SELECT AVG(salary) FROM employees) gives the same answer whichever row is asking. Many questions need a different comparison for each row. Is this employee paid above their department's average? Is this order its customer's most recent? Does this product cost more than the average of its category? The inner query must know which outer row it is answering for, so it refers to the outer row's columns. That makes it a correlated subquery. This lesson covers how to write and read them, the per-group patterns they make easy, their use in the select list, how they perform, and a scope rule that produces some of the most baffling bugs in SQL.

The data

CREATE TABLE employees (id INT PRIMARY KEY, name VARCHAR(40), dept VARCHAR(20), salary INT);
INSERT INTO employees VALUES
  (1, 'Asha', 'Eng',   120000),
  (2, 'Ben',  'Eng',    95000),
  (3, 'Chen', 'Sales',  70000),
  (4, 'Dina', 'Sales',  82000),
  (5, 'Esha', 'Ops',    60000);

Department averages: Eng 107500, Sales 76000, Ops 60000.

Above their own department's average

SELECT e.name, e.dept, e.salary
FROM employees e
WHERE e.salary > (
  SELECT AVG(x.salary)
  FROM employees x
  WHERE x.dept = e.dept
)
ORDER BY e.dept, e.salary DESC, e.name;
employees e (outer)namedeptsalaryAshaEng120000BenEng95000ChenSales70000DinaSales82000EshaOps60000inner query, x.dept = 'Ops'60000AVG = 6000060000 > 60000 is FALSEkept so farnamedeptsalaryAshaEng120000DinaSales82000
A correlated subquery runs once per outer row. Example: WHERE e.salary > (SELECT AVG(x.salary) FROM employees x WHERE x.dept = e.dept)
  1. The inner query mentions e.dept, a column of the outer row, so it cannot run once on its own. Conceptually it runs again for every employee, with that employee's department filled in.
  2. For Asha the inner query averages the 2 Eng salaries: 107500. 120000 > 107500 is TRUE, so Asha is kept.
  3. For Ben the inner query averages the 2 Eng salaries: 107500. 95000 > 107500 is FALSE, so Ben is left out.
  4. For Chen the inner query averages the 2 Sales salaries: 76000. 70000 > 76000 is FALSE, so Chen is left out.
  5. For Dina the inner query averages the 2 Sales salaries: 76000. 82000 > 76000 is TRUE, so Dina is kept.
  6. Esha is alone in Ops, so the average is her own salary, 60000. 60000 > 60000 is FALSE: equal is not above, and she is left out.

The inner query mentions e.dept, a column of the outer row. Conceptually the database does this for every employee: take their department, compute that department's average, compare. For Asha it computes the Eng average (107500); for Chen the Sales average (76000). Esha is alone in Ops, equal to her own average, so > excludes her.

Two aliases of the same table appear — e outside and x inside — exactly as in a self join. They must be different, and every column should be qualified so it is obvious which copy it comes from.

The latest row per group

SELECT o.customer_id, o.id AS order_id, o.order_date, o.amount
FROM orders o
WHERE o.order_date = (
  SELECT MAX(x.order_date)
  FROM orders x
  WHERE x.customer_id = o.customer_id
)
ORDER BY o.customer_id, o.id;

For each order, the subquery finds the latest date among that customer's orders; the order is kept if it is dated on that day. This "greatest-n-per-group" pattern works for any extreme: the most expensive product per category, the first login per user, the highest score per student. Two properties to state in an interview:

  • ties are kept — a customer with two orders on the latest date gets both rows. If exactly one is wanted, add a tie-breaker (AND o.id = (SELECT MAX(x.id) … )) or use ROW_NUMBER() from the window-functions module;
  • customers with no orders do not appear, because there is no order row to start from.

Correlated subqueries in the select list

A correlated scalar subquery can compute a per-row value:

SELECT c.name,
       (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS orders,
       (SELECT MAX(o.order_date) FROM orders o WHERE o.customer_id = c.id) AS last_order
FROM customers c
ORDER BY c.name;

Customers with no orders get 0 and NULL — COUNT over no rows is 0, MAX is NULL — so this is an alternative to a left join with GROUP BY, and it never suffers from fan-out because each subquery aggregates its own table. The cost is that each subquery is a separate lookup per customer; for a handful of per-row figures over an indexed column that is fine, for a dozen it is better to aggregate once in a derived table (next lesson).

EXISTS is usually correlated

-- departments where someone earns more than the department's head
SELECT d.name
FROM departments d
WHERE EXISTS (
  SELECT 1 FROM employees e
  WHERE e.dept_id = d.id AND e.salary > d.head_salary
);

An uncorrelated EXISTS would be true for all rows or none, which is rarely what anyone means. When you see EXISTS, look for the outer column inside it.

How it actually runs

"Once per outer row" is the logical model, not necessarily the plan. MySQL 8:

  • converts IN/EXISTS subqueries into semi joins and NOT EXISTS into antijoins;
  • can turn some scalar subqueries into joins with a derived table (the subquery_to_derived optimizer switch, off by default);
  • otherwise runs the subquery as a DEPENDENT SUBQUERY, once per outer row.

What makes the per-row lookups cheap is an index on the correlated column — orders(customer_id) or orders(customer_id, order_date) for the latest-order query. Without one, each evaluation scans the table, and a 100,000-row outer table means 100,000 scans. If EXPLAIN shows DEPENDENT SUBQUERY on a large table with no usable index, rewrite as a join with a derived table of per-group aggregates.

The scope trap

Name resolution inside a subquery looks in the subquery's own tables first, then in the outer query's. That rule makes correlation possible — and it means a typo can silently correlate:

-- intended: customers who have a row in the `vip` table
SELECT name FROM customers
WHERE id IN (SELECT id FROM vip);   -- but vip's column is called customer_id

vip has no column id, so the inner id resolves to customers.id. The subquery becomes "for each vip row, the outer customer's id", and id IN (id, id, …) is true for every customer whenever vip is non-empty. No error, every row returned. Qualifying the inner column (SELECT v.id FROM vip v) turns the mistake into the error it should be. Qualify every column inside a subquery — this is the single best habit for avoiding subquery bugs.

Correlated subquery or join?

Both can express most per-group comparisons. Guidelines:

  • a per-row condition against an aggregate of related rows (above the department average, latest per customer) reads naturally as a correlated subquery;
  • when the outer query also needs several columns from the related rows, a join is usually clearer;
  • when the related table is large and the outer one too, aggregate once (derived table) and join.

Pitfalls

  • Reusing the same alias inside and outside, so the "correlation" refers to the inner table itself.
  • Unqualified inner columns silently binding to the outer query.
  • Forgetting that latest-per-group returns ties.
  • A correlated aggregate over a large table with no index on the correlated column.
  • Expecting groups with no rows to appear when the outer query starts from the child table.

Key takeaways

  • A correlated subquery refers to an outer column and is logically evaluated per outer row.
  • It is the natural way to compare a row with an aggregate of its own group.
  • col = (SELECT MAX(col) … WHERE same group) returns the latest row per group, ties included.
  • Correlated scalar subqueries in the select list compute per-row counts without fan-out.
  • Index the correlated column, and qualify every inner column to avoid accidental correlation.

Common questions

What is the difference between a correlated and a non-correlated subquery?

A non-correlated subquery does not mention the outer query, so it returns the same result for every row and runs once. A correlated subquery uses an outer column, such as e.dept, so its result changes from row to row and it is evaluated per outer row, although the optimizer may rewrite it into a join.

Are correlated subqueries slow?

They can be, because the subquery may run once per outer row. With an index on the correlated column, such as employees(dept), each run is a quick index lookup, and MySQL 8 often decorrelates EXISTS and IN into semi joins. A correlated aggregate over a large table is the case to rewrite as a join with a derived table.

How do I get each customer's latest order in SQL?

Keep the orders whose date equals the maximum date of that customer's orders: WHERE o.order_date = (SELECT MAX(x.order_date) FROM orders x WHERE x.customer_id = o.customer_id). Customers with two orders on the latest date get both rows. Window functions such as ROW_NUMBER are the other common answer.

Why does my subquery work even though the column does not exist in its table?

An unqualified column that the inner table lacks is resolved from the outer query, so WHERE id IN (SELECT id FROM orders) may silently compare the outer id with itself when orders has no id column. Qualify every column inside a subquery with its alias to make this mistake an error instead.

Exercises

Above their department's average

List the employees who earn more than the average salary of their own department. Employees with no department (NULL dept) are not compared and never listed.

Table: employees(id, name, dept, salary).

idnamedeptsalary
1AshaEng120000
2BenEng95000
3FaridEng101000
4GopalEng110000
5ChenSales70000
6DinaSales82000
7EshaOps60000

Output: columns name, dept, salary, ordered by dept, then salary descending, then name.

name | dept | salary
Asha | Eng | 120000
Gopal | Eng | 110000
Dina | Sales | 82000

Each customer's latest order

For each customer, list their most recent order. If several orders share the latest date, list them all. Customers with no orders are not listed, and orders without a known customer are ignored.

Tables: customers(id, name) and orders(id, customer_id, order_date, amount).

customers.idname
1Asha
2Ben
3Chen
orders.idcustomer_idorder_dateamount
1012024-01-05250
1112024-02-11100
1222024-02-20400
1322024-02-2050
1422024-01-01999

Output: columns customer, order_id, order_date, amount, ordered by customer, then order_id.

customer | order_id | order_date | amount
Asha | 11 | 2024-02-11 | 100
Ben | 12 | 2024-02-20 | 400
Ben | 13 | 2024-02-20 | 50

In this module: Subqueries

← IN and EXISTS — testing membership with a subquery · Derived tables — a subquery in FROM →