Tier 4 · Advanced

Correlated Subqueries in SQL

By Owen Middleton · Updated September 2026 · Examples run on PostgreSQL 17

What are Correlated Subqueries in SQL?

A correlated subquery re-runs for each row the outer query processes, using that row's values as input. The subquery's result is different for every outer row — it's not a fixed value computed once and reused.

The clearest use case: find every employee earning above the average salary for their own department. The challenge is that "their department" changes for every employee. Computing one average for the whole table isn't useful — you need a per-department average, evaluated row by row.

How do you compare a row to its own group average?

With a correlated subquery, you compute the average once per employee, dynamically, using that employee's department:

SELECT
    e.employee_id,
    e.name,
    e.salary,
    e.department_id
FROM employees e
WHERE e.salary > (
    SELECT AVG(salary)
    FROM employees dept_avg
    WHERE dept_avg.department_id = e.department_id
)

For each employee row, the subquery runs with that employee's department_id. Employee 12 in department 3 triggers the subquery with department_id = 3. Employee 45 in department 7 triggers it with department_id = 7. The outer WHERE uses whichever result corresponds to the current row.

Can a correlated subquery go in the SELECT list?

Correlated subqueries also work in the SELECT list to compute a value per outer row:

SELECT
    d.department_id,
    d.department_name,
    (
        SELECT COUNT(*)
        FROM employees e
        WHERE e.department_id = d.department_id
    ) AS headcount
FROM departments d

For each department, the subquery counts its employees and attaches the result as a column. This is equivalent to a LEFT JOIN with GROUP BY — same output, different structure. The correlated form is sometimes cleaner when you only need one computed value per outer row and the join version would add complexity.

Here's the SELECT-list form on real data:

SELECT c.id AS customer_id, c.name,
  (SELECT MAX(ordered_at)::date FROM orders o WHERE o.customer_id = c.id) AS last_order_date
FROM customers c
LIMIT 10

How does EXISTS use a correlated subquery?

EXISTS and correlated subqueries

EXISTS — which you've already seen — relies on this same mechanism. The subquery inside EXISTS references the outer row to check whether any matching rows exist at all:

SELECT customer_id
FROM customers c
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.customer_id = c.customer_id
)

For each customer, the subquery checks whether any orders reference that customer. EXISTS stops as soon as it finds one match — it doesn't need to count them all. This makes it efficient for "does at least one match exist?" questions.

Why are correlated subqueries slow on large tables?

The one thing that trips people up

Correlated subqueries can be slow on large tables because the subquery executes once per outer row. A query with 500,000 outer rows runs the subquery 500,000 times. PostgreSQL's planner can sometimes rewrite them as joins internally, but it's not guaranteed.

When a correlated subquery can be expressed as a window function or a join, those alternatives are usually faster. Correlated subqueries are most appropriate when the per-row computation is genuinely unique and can't be cleanly expressed as a join without significantly complicating the query.

Practice Correlated Subqueries in SQL

Practice · easy ecommerce · Brightlane

Brightlane's CRM team needs a complete customer activity view that shows the number of orders each customer has placed.

Write a query to return every customer's ID, name, and total order count.

Assumptions:

  • A customer's order count is the number of orders linked to that customer_id.
  • Every customer must appear, including customers with no orders. Customers with no orders should show an order count of 0.

Output:

  • One row per customer, with columns id, name, and order_count.
Schema · ecommerce5 tables? = nullable
categories
idinteger
nametext
parent_id?integer
products
idinteger
nametext
category_id?integer
pricenumeric
stock_qtyinteger
attributes?jsonb
order_items
idinteger
order_id?integer
product_id?integer
quantityinteger
unit_pricenumeric
customers
idinteger
nametext
emailtext
city?text
countrytext
created_attimestamptz
is_activeboolean
orders
idinteger
customer_id?integer
ordered_attimestamptz
statustext
total_amountnumeric

Run previews · Check grades

Write a query, then run it to see results here.

Worked solution

The full breakdown walks through the shape, each clause, why this approach beats the alternatives, and the trap to avoid.

See the full worked solution
In the game

Station Zero, our free browser SQL game, teaches this concept inside a story. No signup.

correlate a subquery in Station Zero

9 Correlated Subqueries practice problems

Start learning to practice all 9 Correlated Subqueries problems, with instant grading and mastery tracking.

Common questions about Correlated Subqueries

Can a correlated subquery reference more than one outer column?

Yes, as many as the comparison needs. Matching on both the customer and the status is ordinary, and each extra reference simply narrows what the inner query computes for that row.

Can you always rewrite a correlated subquery as a join?

Usually, by aggregating the inner query once per key and joining to that result. The rewritten form returns the same rows and does the aggregation once instead of once per outer row, which is why it is the standard move when a plan shows the subquery running too often.

Is EXISTS a correlated subquery?

Almost always, yes. The subquery inside EXISTS references the outer row to decide whether any match exists for it, which is what correlation means. EXISTS is simply the form that asks whether anything came back rather than what it was.

How you actually get good at SQL

Reading explains SQL. Writing it, over and over with instant feedback, is what makes you fluent.

That's the whole SQLMaxx loop: 600+ real problems, instant AI feedback, mastery you can actually see, and spaced review that won't let you forget.

A stack of SQL practice problem cards, the top card showing an employees table.
615 problems · 66 concepts

Real problems. Not toy examples.

615 hand-built problems spanning all 66 concepts, from basic SELECTs to window functions, built on real schemas and real business questions, the kind you'll actually get asked on the job. Enough reps to make SQL automatic.

A retro computer showing a SQL query marked correct with a green checkmark.
Instant AI feedback

Write a query. Know if it's right in one second.

No copying an answer and hoping it clicked. The AI grader checks your real query against real data, catches exactly what's wrong, and explains the fix in plain English, like a senior analyst reading over your shoulder on every problem.

A circular mastery progress dial filling from blue to green, the SQLMaxx diamond at its center.
Mastery tracking

Stop guessing whether you actually know it.

SQLMaxx tracks every concept and shows you what you've mastered and what's still shaky. Your skills fill in one concept at a time, so 'I think I get joins' becomes something you can prove.

A SQL query editor circled by a blue return arrow with a clock, scheduled to come back for review.
Spaced review

Learn it once. Keep it for good.

Most of what you learn this week fades by next week. So when a concept comes due for review, SQLMaxx hands you a fresh problem to solve from a blank editor, not a flashcard to re-read. A research-backed spaced-repetition algorithm (FSRS) times each return for right before you'd forget, so your SQL is still there months later, when the interview or the job actually needs it.