SQL Correlated Subqueries: How They Work, With Examples
A correlated subquery is a subquery that uses a value from the current row of the outer query, so its answer can be different for every row. It answers questions such as "which employees earn more than the average in their own department?", where the comparison value depends on the row being checked.
What Is a Correlated Subquery?
Start with a normal subquery. This compares every employee with the company-wide average salary. The subquery has no link to the outer query, so it is worked out once:
SELECT COUNT(*) AS above_company_average
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
▶ Run this query in the SQL Playground
| above_company_average |
|---|
| 55 |
Now compare each employee with the average of their own department. The subquery needs to know which department the current employee is in, so it refers to the outer row through the alias e:
SELECT e.first_name, e.last_name, e.department, e.salary
FROM employees e
WHERE e.salary > (SELECT AVG(x.salary)
FROM employees x
WHERE x.department = e.department)
ORDER BY e.department, e.salary DESC
LIMIT 8;
▶ Run this query in the SQL Playground
| first_name | last_name | department | salary |
|---|---|---|---|
| Nancy | Thompson | Customer Success | 203999 |
| Maria | Garcia | Customer Success | 191120 |
| Barbara | Baker | Customer Success | 188280 |
| Melissa | Thomas | Customer Success | 178391 |
| Dorothy | Campbell | Customer Success | 165118 |
| Charles | Allen | Customer Success | 162901 |
| Sarah | Ramirez | Customer Success | 152154 |
| Edward | Ramirez | Customer Success | 137737 |
x.department = e.department is the correlation. It makes the subquery a different question for each employee: an Engineering employee is compared with the Engineering average, a Sales employee with the Sales average. 59 employees beat their department average, against 55 who beat the company average.
SELECT COUNT(*) AS above_department_average
FROM employees e
WHERE e.salary > (SELECT AVG(x.salary)
FROM employees x
WHERE x.department = e.department);
▶ Run this query in the SQL Playground
| above_department_average |
|---|
| 59 |
Syntax and How It Runs
SELECT ...
FROM outer_table o
WHERE o.column operator (SELECT aggregate(i.column)
FROM inner_table i
WHERE i.key = o.key); -- refers to the outer row
Conceptually, the database processes it like this:
| Step | What happens |
|---|---|
| 1 | Take the next row of the outer query |
| 2 | Put that row's values (here its department) into the subquery |
| 3 | Run the subquery and get its result (that department's average) |
| 4 | Use the result to decide whether the outer row is kept |
| 5 | Repeat for every outer row |
Because the subquery refers to e, it cannot be run on its own: run it separately and the database reports that e.department does not exist. In practice optimizers often rewrite a correlated subquery into a join, so "once per row" describes the meaning, not necessarily the work done.
| Normal subquery | Correlated subquery | |
|---|---|---|
| Refers to the outer query | No | Yes |
| Runs (logically) | Once | Once per outer row |
| Can run on its own | Yes | No |
| Typical use | Compare with one overall value | Compare with a value for the row's own group |
Correlated Subquery Examples
The highest-paid employee in each department
SELECT e.department, e.first_name, e.last_name, e.salary
FROM employees e
WHERE e.salary = (SELECT MAX(x.salary)
FROM employees x
WHERE x.department = e.department)
ORDER BY e.salary DESC;
▶ Run this query in the SQL Playground
| department | first_name | last_name | salary |
|---|---|---|---|
| Marketing | Steven | Nguyen | 204449 |
| Customer Success | Nancy | Thompson | 203999 |
| Operations | John | Lee | 203467 |
| Engineering | Donna | Johnson | 199029 |
| Finance | Paul | Robinson | 196032 |
| Sales | Laura | Rivera | 195759 |
| Design | Amy | Nguyen | 194543 |
| Legal | Kenneth | Campbell | 192220 |
| HR | Ronald | Ramirez | 191037 |
| Product | Steven | Adams | 188273 |
Products priced above their category average
SELECT p.category, p.name, p.price
FROM products p
WHERE p.price > (SELECT AVG(q.price)
FROM products q
WHERE q.category = p.category)
ORDER BY p.category, p.price DESC
LIMIT 8;
▶ Run this query in the SQL Playground
| category | name | price |
|---|---|---|
| Automotive | 2000A Jump Starter Pack | 1948.86 |
| Automotive | Back Seat Organizer | 1804.31 |
| Automotive | Leather Steering Wheel Cover | 1727.7 |
| Automotive | Magnetic Phone Mount Pro | 1599.08 |
| Automotive | Leather Steering Wheel Cover | 1527.59 |
| Automotive | 4K Dual Dash Camera | 1229.81 |
| Books | The Manager's Path | 1979.28 |
| Books | Clean Code (Martin) | 1891.09 |
Each customer's most recent order
SELECT o.user_id, o.id AS order_id, o.order_date, o.total
FROM orders o
WHERE o.order_date = (SELECT MAX(x.order_date)
FROM orders x
WHERE x.user_id = o.user_id)
ORDER BY o.user_id
LIMIT 8;
▶ Run this query in the SQL Playground
| user_id | order_id | order_date | total |
|---|---|---|---|
| 1 | 109 | 2022-09-26 | 4064.75 |
| 2 | 13 | 2023-04-02 | 1618.29 |
| 4 | 11 | 2024-11-15 | 1518.36 |
| 5 | 127 | 2024-05-18 | 4924.23 |
| 6 | 23 | 2023-01-19 | 3252.76 |
| 7 | 198 | 2023-12-25 | 2892.22 |
| 8 | 200 | 2024-05-17 | 1283.83 |
| 10 | 65 | 2024-11-17 | 2075.37 |
If a customer had two orders on their latest date, both would appear. Add a tie-breaker, such as comparing id instead of the date, when you need exactly one row per customer.
Correlated Subqueries in SELECT
A correlated subquery that returns one value can be a column of its own. This shows each customer with their number of orders and their largest order:
SELECT u.id, u.first_name, u.last_name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS orders,
(SELECT MAX(total) FROM orders o WHERE o.user_id = u.id) AS largest_order
FROM users u
ORDER BY u.id
LIMIT 6;
▶ Run this query in the SQL Playground
| id | first_name | last_name | orders | largest_order |
|---|---|---|---|---|
| 1 | Jennifer | Torres | 1 | 4064.75 |
| 2 | Donna | Adams | 1 | 1618.29 |
| 3 | Kenneth | Young | 0 | NULL |
| 4 | Michael | Carter | 3 | 2501.23 |
| 5 | Robert | Campbell | 1 | 4924.23 |
| 6 | Deborah | Jackson | 2 | 4151.02 |
For a customer with no orders, COUNT returns 0 and MAX returns NULL. A subquery in SELECT must return at most one row; if it can return more, the query fails in most databases (SQLite silently uses the first row).
Correlated Subqueries With EXISTS and NOT EXISTS
EXISTS is almost always used with a correlated subquery. It keeps the rows for which the subquery finds at least one match:
SELECT COUNT(*) AS reviewed_products
FROM products p
WHERE EXISTS (SELECT 1
FROM reviews r
WHERE r.product_id = p.id);
▶ Run this query in the SQL Playground
| reviewed_products |
|---|
| 93 |
NOT EXISTS finds the rows with no match, here the customers who have never ordered:
SELECT u.id, u.first_name, u.last_name
FROM users u
WHERE NOT EXISTS (SELECT 1
FROM orders o
WHERE o.user_id = u.id)
ORDER BY u.id
LIMIT 6;
▶ Run this query in the SQL Playground
| id | first_name | last_name |
|---|---|---|
| 3 | Kenneth | Young |
| 9 | Sandra | Jackson |
| 12 | Richard | Thomas |
| 14 | Jeffrey | Lopez |
| 17 | Stephanie | Mitchell |
| 19 | Barbara | Lee |
NOT EXISTS is the safe way to ask "has no match"; NOT IN returns no rows at all if its list contains a NULL. See IN vs EXISTS.
Correlated Subquery vs JOIN vs Window Function
The "above department average" question can be written three ways. All three return the same 59 employees.
JOIN to a grouped result
SELECT COUNT(*) AS above_department_average
FROM employees e
JOIN (SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department) d ON d.department = e.department
WHERE e.salary > d.avg_salary;
▶ Run this query in the SQL Playground
| above_department_average |
|---|
| 59 |
Window function
SELECT COUNT(*) AS above_department_average
FROM (SELECT salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg
FROM employees) t
WHERE salary > dept_avg;
▶ Run this query in the SQL Playground
| above_department_average |
|---|
| 59 |
| Approach | Strength | Weakness |
|---|---|---|
| Correlated subquery | Reads like the question | Can be slow on large tables if the optimizer does not rewrite it |
| JOIN to a grouped subquery | The averages are computed once | More code; the grouped step is separate from the filter |
| Window function | One pass, and the average is available as a column | Needs a subquery or CTE to filter on it |
For existence checks, EXISTS is usually the clearest. For "compare with my group's value", window functions are often the fastest on large tables. Check the plans with EXPLAIN when it matters.
Performance Tips
| Tip | Why |
|---|---|
Index the correlation column, for example orders(user_id) | Each subquery run becomes an index lookup |
Prefer EXISTS to (SELECT COUNT(*) ...) > 0 | EXISTS can stop at the first match |
| Consider a window function or a JOIN to a grouped result | The group values are computed once instead of per row |
| Look at the plan | EXPLAIN shows whether the database already turned the subquery into a join |
Common Correlated Subquery Mistakes
Five errors and how to avoid them.
1. Forgetting the correlation
SELECT *
FROM employees e
WHERE salary > (SELECT AVG(salary)
FROM employees);
SELECT *
FROM employees e
WHERE salary > (SELECT AVG(x.salary)
FROM employees x
WHERE x.department = e.department);
Without the WHERE inside, the subquery is the same for every row. In the Playground that changes the answer from 59 employees to 55.
2. Using the same alias inside and outside
SELECT *
FROM employees e
WHERE salary > (SELECT AVG(salary)
FROM employees e
WHERE e.department = e.department);
SELECT *
FROM employees e
WHERE salary > (SELECT AVG(x.salary)
FROM employees x
WHERE x.department = e.department);
The inner alias hides the outer one, so the condition is always true and the subquery becomes the company average again.
3. A scalar subquery that returns several rows
SELECT u.first_name,
(SELECT total FROM orders o
WHERE o.user_id = u.id) AS order_total
FROM users u;
SELECT u.first_name,
(SELECT SUM(total) FROM orders o
WHERE o.user_id = u.id) AS order_total
FROM users u;
A subquery used as a value must return one row. Customers with several orders break the first version (SQLite quietly picks one, which is worse).
4. NOT IN instead of NOT EXISTS
WHERE id NOT IN (SELECT manager_id FROM employees)
WHERE NOT EXISTS (SELECT 1 FROM employees r
WHERE r.manager_id = e.id)
manager_id contains NULLs in the Playground, so the NOT IN version returns no rows.
5. A correlated subquery on a huge table with no index
-- orders has millions of rows and no index on user_id
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)
CREATE INDEX idx_orders_user_id ON orders(user_id);
Without the index each check may read the whole table, so the cost grows with outer rows times inner rows.
Correlated Subquery Cheat Sheet
| Task | Pattern |
|---|---|
| Above my group's average | WHERE x > (SELECT AVG(i.x) FROM t i WHERE i.g = o.g) |
| Top value per group | WHERE x = (SELECT MAX(i.x) FROM t i WHERE i.g = o.g) |
| Per-row value in SELECT | (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) |
| Has a match | WHERE EXISTS (SELECT 1 FROM t i WHERE i.k = o.k) |
| Has no match | WHERE NOT EXISTS (SELECT 1 FROM t i WHERE i.k = o.k) |
| Window function alternative | AVG(x) OVER (PARTITION BY g) |
Practice Correlated Subqueries Queries
6 exercises on the real Playground tables.
Write each query yourself first. Every solution has been run against the Playground database.
-
1Easy Count the orders that are larger than the same customer's average order. Table:
ordersShow solution
SELECT COUNT(*) AS above_own_average FROM orders o WHERE o.total > (SELECT AVG(x.total) FROM orders x WHERE x.user_id = o.user_id);▶ Run this query in the SQL Playground
The subquery averages only the orders of the customer who placed the current order.
-
2Easy Show the first 6 customers with the number of reviews each has written. Table:
users, reviewsShow solution
SELECT u.id, u.first_name, (SELECT COUNT(*) FROM reviews r WHERE r.user_id = u.id) AS reviews FROM users u ORDER BY u.id LIMIT 6;▶ Run this query in the SQL Playground
A correlated subquery in SELECT gives one value per customer, 0 when there is none.
-
3Medium List the most recently hired employee in each department. Table:
employeesShow solution
SELECT e.department, e.first_name, e.last_name, e.hire_date FROM employees e WHERE e.hire_date = (SELECT MAX(x.hire_date) FROM employees x WHERE x.department = e.department) ORDER BY e.hire_date DESC;▶ Run this query in the SQL Playground
MAX over the employee's own department, compared with their hire date.
-
4Medium Find the products whose average review rating is below 2.5. Show 8, lowest first. Table:
products, reviewsShow solution
SELECT p.name, (SELECT ROUND(AVG(r.rating), 2) FROM reviews r WHERE r.product_id = p.id) AS avg_rating FROM products p WHERE (SELECT AVG(r.rating) FROM reviews r WHERE r.product_id = p.id) < 2.5 ORDER BY avg_rating, p.name LIMIT 8;▶ Run this query in the SQL Playground
Products without reviews have a NULL average, and NULL < 2.5 is not true, so they drop out.
-
5Medium Show each department with the number of employees who earn more than 150,000 in it. Table:
departments, employeesShow solution
SELECT d.name, (SELECT COUNT(*) FROM employees e WHERE e.department_id = d.id AND e.salary > 150000) AS high_earners FROM departments d ORDER BY high_earners DESC, d.name;▶ Run this query in the SQL Playground
The correlation uses department_id, the key that links the two tables.
-
6Hard Rewrite exercise 1 with a window function and confirm the count is the same. Table:
ordersShow solution
SELECT COUNT(*) AS above_own_average FROM (SELECT total, AVG(total) OVER (PARTITION BY user_id) AS user_avg FROM orders) t WHERE total > user_avg;▶ Run this query in the SQL Playground
AVG ... OVER (PARTITION BY user_id) puts each customer's average on every one of their orders in a single pass.
📝 Quiz — Correlated Subqueries
Test your understanding · 0/20 answered
What is a correlated subquery?
The reference to the outer row is what makes it correlated.
Can a correlated subquery run on its own?
Run alone, it fails because the outer alias is unknown.
What is the key difference between a normal and a correlated subquery?
In the Playground the two versions of "above average" give 55 and 59 employees.
What is EXISTS usually paired with?
The subquery refers to the outer row to look for a match.
What does NOT EXISTS return when the subquery finds no rows?
That is how it finds missing records, such as customers without orders.
Why can correlated subqueries be slower than normal ones?
Optimizers and indexes often reduce the cost a lot.
Which condition finds employees above their own department's average?
The first option compares with the company average instead.
What does the correlation usually look like?
For example WHERE x.department = e.department.
What is the conceptual order of execution?
That is the meaning; the real plan may be a join.
Which operator finds rows with no related record?
NOT EXISTS with a correlated subquery, such as customers who never ordered.
How do you find the highest salary within each department?
The second option only finds the single highest salary in the company.
What happens if you forget the correlation condition?
It runs without error but answers a different question.
Can a correlated subquery be used in SELECT?
It must return at most one row.
Which is safer with NULLs, NOT IN or NOT EXISTS?
A NULL in the NOT IN list makes it return no rows.
How can a correlated subquery often be rewritten for speed?
Both compute the group values once.
Which feature is the modern alternative to many correlated subqueries?
AVG(salary) OVER (PARTITION BY department) gives each row its group average.
How do you find customers with at least one order using EXISTS?
The correlated subquery looks for an order for each customer.
Why is SELECT 1 used inside EXISTS?
SELECT * behaves the same.
How can you see whether the optimizer turned a correlated subquery into a join?
The plan shows the join or semi-join the database chose.
Why are correlated subqueries common in interviews?
"Above department average" and "top earner per department" are classic questions.
Correlated Subqueries Interview Questions
Q1. What is a correlated subquery?
A subquery that refers to a column of the outer query, so it is evaluated for each outer row and can return a different value each time.
Q2. Give an example where you need one.
Employees who earn more than their own department's average: WHERE salary > (SELECT AVG(x.salary) FROM employees x WHERE x.department = e.department).
Q3. Are correlated subqueries slow?
They can be on large tables, because logically they run once per outer row. Optimizers often rewrite them as joins, and an index on the correlation column helps a lot. Check the plan.
Q4. How else could you write "above department average"?
Join to a grouped subquery of department averages, or use AVG(salary) OVER (PARTITION BY department) in a subquery or CTE and filter on it.
Q5. What happens if a correlated scalar subquery returns two rows?
Most databases raise an error. SQLite uses the first row without warning. Aggregate the subquery (SUM, MAX, COUNT) or make sure it can only match one row.
Q6. Why is NOT EXISTS preferred for "has no match"?
It is correct when the other column contains NULLs, unlike NOT IN, and databases execute it efficiently as an anti-join.
Frequently Asked Questions
What is a correlated subquery in SQL?
A subquery that refers to a column of the outer query, so it cannot run on its own. Conceptually it is evaluated once for every row of the outer query, using that row's values.
How does a correlated subquery work?
The database takes an outer row, puts that row's values into the inner query, runs the inner query, uses the result to decide whether the row qualifies, and then repeats this for the next outer row.
Why is it called "correlated"?
Because the inner query is linked to the outer query through a shared column reference, such as x.department = e.department, instead of being an independent query.
What is the difference between a subquery and a correlated subquery?
A normal subquery is independent and runs once, producing one result. A correlated subquery refers to the outer row and has to be evaluated for each outer row, because its result can change from row to row.
Are correlated subqueries slow?
They can be, because they may be evaluated once per outer row. Optimizers often rewrite them into joins, and an index on the correlation column keeps them fast.
Can correlated subqueries be replaced by JOINs?
Often, yes. A per-group comparison can usually be written as a JOIN to a grouped subquery or with a window function, which may be faster on large tables.
What is EXISTS in SQL?
An operator that is true when its subquery returns at least one row. It is almost always used with a correlated subquery that looks for a related match.
What is NOT EXISTS in SQL?
The opposite of EXISTS: true when the correlated subquery finds no rows. It is the standard way to find missing related records, such as customers with no orders.
Can a correlated subquery run independently?
No. It refers to a column of the outer query, so running it on its own gives an error.
Are correlated subqueries important for SQL interviews?
Yes. Questions about comparing a row with its own group, EXISTS and NOT EXISTS, and when to use a JOIN or window function instead come up often.