SQLab Hub
Lesson 20 · Correlated Subqueries
Lesson 20 · Subqueries

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.

↻ Depends on the outer row ▶ Runnable Playground examples 🧪 6 practice exercises 📝 20-question quiz ⏱ ~25 min

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:

SQLite · runs in the Playground
SELECT COUNT(*) AS above_company_average
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);

▶ Run this query in the SQL Playground

Employees above the company average
above_company_average
55
✓ 1 row · real output from the Playground database

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:

SQLite · runs in the Playground
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

Employees above their own department average
first_namelast_namedepartmentsalary
NancyThompsonCustomer Success203999
MariaGarciaCustomer Success191120
BarbaraBakerCustomer Success188280
MelissaThomasCustomer Success178391
DorothyCampbellCustomer Success165118
CharlesAllenCustomer Success162901
SarahRamirezCustomer Success152154
EdwardRamirezCustomer Success137737
✓ 8 rows · real output from the Playground database

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.

SQLite · runs in the Playground
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

Number of employees above their department average
above_department_average
59
✓ 1 row · real output from the Playground database

Syntax and How It Runs

SQL · syntax
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:

StepWhat happens
1Take the next row of the outer query
2Put that row's values (here its department) into the subquery
3Run the subquery and get its result (that department's average)
4Use the result to decide whether the outer row is kept
5Repeat 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 subqueryCorrelated subquery
Refers to the outer queryNoYes
Runs (logically)OnceOnce per outer row
Can run on its ownYesNo
Typical useCompare with one overall valueCompare with a value for the row's own group

Correlated Subquery Examples

The highest-paid employee in each department

SQLite · runs in the Playground
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

Top earner per department
departmentfirst_namelast_namesalary
MarketingStevenNguyen204449
Customer SuccessNancyThompson203999
OperationsJohnLee203467
EngineeringDonnaJohnson199029
FinancePaulRobinson196032
SalesLauraRivera195759
DesignAmyNguyen194543
LegalKennethCampbell192220
HRRonaldRamirez191037
ProductStevenAdams188273
✓ 10 rows · real output from the Playground database

Products priced above their category average

SQLite · runs in the Playground
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

Products above their category's average price
categorynameprice
Automotive2000A Jump Starter Pack1948.86
AutomotiveBack Seat Organizer1804.31
AutomotiveLeather Steering Wheel Cover1727.7
AutomotiveMagnetic Phone Mount Pro1599.08
AutomotiveLeather Steering Wheel Cover1527.59
Automotive4K Dual Dash Camera1229.81
BooksThe Manager's Path1979.28
BooksClean Code (Martin)1891.09
✓ 8 rows · real output from the Playground database

Each customer's most recent order

SQLite · runs in the Playground
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

Latest order per customer
user_idorder_idorder_datetotal
11092022-09-264064.75
2132023-04-021618.29
4112024-11-151518.36
51272024-05-184924.23
6232023-01-193252.76
71982023-12-252892.22
82002024-05-171283.83
10652024-11-172075.37
✓ 8 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

Order count and largest order per customer
idfirst_namelast_nameorderslargest_order
1JenniferTorres14064.75
2DonnaAdams11618.29
3KennethYoung0NULL
4MichaelCarter32501.23
5RobertCampbell14924.23
6DeborahJackson24151.02
✓ 6 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

Products with at least one review
reviewed_products
93
✓ 1 row · real output from the Playground database

NOT EXISTS finds the rows with no match, here the customers who have never ordered:

SQLite · runs in the Playground
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

Customers who never ordered
idfirst_namelast_name
3KennethYoung
9SandraJackson
12RichardThomas
14JeffreyLopez
17StephanieMitchell
19BarbaraLee
✓ 6 rows · real output from the Playground database

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

SQLite · runs in the Playground
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

The same question with a JOIN
above_department_average
59
✓ 1 row · real output from the Playground database

Window function

SQLite · runs in the Playground
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

The same question with a window function
above_department_average
59
✓ 1 row · real output from the Playground database
ApproachStrengthWeakness
Correlated subqueryReads like the questionCan be slow on large tables if the optimizer does not rewrite it
JOIN to a grouped subqueryThe averages are computed onceMore code; the grouped step is separate from the filter
Window functionOne pass, and the average is available as a columnNeeds 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

TipWhy
Index the correlation column, for example orders(user_id)Each subquery run becomes an index lookup
Prefer EXISTS to (SELECT COUNT(*) ...) > 0EXISTS can stop at the first match
Consider a window function or a JOIN to a grouped resultThe group values are computed once instead of per row
Look at the planEXPLAIN 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

❌ Compares with the company average
SQL
SELECT *
FROM employees e
WHERE salary > (SELECT AVG(salary)
                FROM employees);
✅ Compares with the department average
SQL
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

❌ Compares a row with itself
SQL
SELECT *
FROM employees e
WHERE salary > (SELECT AVG(salary)
                FROM employees e
                WHERE e.department = e.department);
✅ Two different aliases
SQL
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

❌ Error: more than one row
SQL
SELECT u.first_name,
       (SELECT total FROM orders o
        WHERE o.user_id = u.id) AS order_total
FROM users u;
✅ Aggregate to one value
SQL
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

❌ Empty if the list has a NULL
SQL
WHERE id NOT IN (SELECT manager_id FROM employees)
✅ Always correct
SQL
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

❌ One scan per outer row
SQL
-- orders has millions of rows and no index on user_id
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)
✅ Index the matched column
SQL
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

TaskPattern
Above my group's averageWHERE x > (SELECT AVG(i.x) FROM t i WHERE i.g = o.g)
Top value per groupWHERE 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 matchWHERE EXISTS (SELECT 1 FROM t i WHERE i.k = o.k)
Has no matchWHERE NOT EXISTS (SELECT 1 FROM t i WHERE i.k = o.k)
Window function alternativeAVG(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.

  1. 1Easy Count the orders that are larger than the same customer's average order. Table: orders

    Show solution
    SQLite · runs in the Playground
    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.

  2. 2Easy Show the first 6 customers with the number of reviews each has written. Table: users, reviews

    Show solution
    SQLite · runs in the Playground
    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.

  3. 3Medium List the most recently hired employee in each department. Table: employees

    Show solution
    SQLite · runs in the Playground
    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.

  4. 4Medium Find the products whose average review rating is below 2.5. Show 8, lowest first. Table: products, reviews

    Show solution
    SQLite · runs in the Playground
    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.

  5. 5Medium Show each department with the number of employees who earn more than 150,000 in it. Table: departments, employees

    Show solution
    SQLite · runs in the Playground
    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.

  6. 6Hard Rewrite exercise 1 with a window function and confirm the count is the same. Table: orders

    Show solution
    SQLite · runs in the Playground
    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

Question 1 of 20

What is a correlated subquery?

Question 2 of 20

Can a correlated subquery run on its own?

Question 3 of 20

What is the key difference between a normal and a correlated subquery?

Question 4 of 20

What is EXISTS usually paired with?

Question 5 of 20

What does NOT EXISTS return when the subquery finds no rows?

Question 6 of 20

Why can correlated subqueries be slower than normal ones?

Question 7 of 20

Which condition finds employees above their own department's average?

Question 8 of 20

What does the correlation usually look like?

Question 9 of 20

What is the conceptual order of execution?

Question 10 of 20

Which operator finds rows with no related record?

Question 11 of 20

How do you find the highest salary within each department?

Question 12 of 20

What happens if you forget the correlation condition?

Question 13 of 20

Can a correlated subquery be used in SELECT?

Question 14 of 20

Which is safer with NULLs, NOT IN or NOT EXISTS?

Question 15 of 20

How can a correlated subquery often be rewritten for speed?

Question 16 of 20

Which feature is the modern alternative to many correlated subqueries?

Question 17 of 20

How do you find customers with at least one order using EXISTS?

Question 18 of 20

Why is SELECT 1 used inside EXISTS?

Question 19 of 20

How can you see whether the optimizer turned a correlated subquery into a join?

Question 20 of 20

Why are correlated subqueries common in interviews?

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.