Post

Correlated Subqueries — Row-by-Row Subqueries

Correlated Subqueries — Row-by-Row Subqueries

title: “Correlated Subqueries” part: 2 topic_number: 7 slug: “correlated-subqueries” difficulty: “Intermediate” prerequisites: “nested-subqueries” —

Correlated Subqueries — Row-by-Row Subqueries

What Is It?

A correlated subquery is a subquery that references columns from the outer query. Unlike regular subqueries (which run once), correlated subqueries run once for each row processed by the outer query.

Real-world analogy: Like asking “How do I compare to my peers?” — the answer depends on who “I” am, so you have to answer it separately for each person.

When you’d use it:

  • Compare each row to aggregates of related rows
  • Find “top N per group”
  • Row-by-row conditional logic
  • Complex filtering based on related data

Warning: Can be slow on large tables (runs many times).


Why This Is Confusing

A regular subquery runs once and produces a fixed value. A correlated subquery runs once per row and produces a different value each time, because it uses a column from the current row.

1
2
3
4
5
6
7
8
9
10
Regular subquery:
  SELECT AVG(salary) FROM employees   →  runs once → 72000 (same for every row)

Correlated subquery:
  SELECT AVG(salary) FROM employees e2
  WHERE e2.department_id = e.department_id   →  runs per row:
    Row 1: Alice is in dept 1 → avg salary of dept 1 = 85000
    Row 2: Frank is in dept 2 → avg salary of dept 2 = 87500
    Row 3: Carol is in dept 3 → avg salary of dept 3 = 69000
    ...and so on for every employee

The e.department_id is what makes it correlated — it’s from the outer query’s current row.



Syntax Breakdown

1
2
3
4
5
6
7
SELECT columns
FROM table1 outer
WHERE condition (
    SELECT ...
    FROM table2 inner
    WHERE inner.col = outer.col  -- References outer query!
);

Key difference from regular subquery:

  • Regular: WHERE col > (SELECT AVG(salary) FROM employees) — runs once
  • Correlated: WHERE col > (SELECT AVG(salary) FROM employees e2 WHERE e2.dept_id = e.dept_id) — runs per row

Basic Examples

Find Above-Department-Average Earners

1
2
3
4
5
6
7
8
9
10
11
12
SELECT 
    first_name,
    last_name,
    salary,
    department_id
FROM employees e
WHERE salary > (
    SELECT AVG(salary)
    FROM employees e2
    WHERE e2.department_id = e.department_id  -- Correlated!
)
ORDER BY department_id, salary DESC;

Expected output:

first_name last_name salary department_id
Sam Clark 95000.00 1
Paul Garcia 87000.00 1
Frank Miller 93000.00 2
Bob Smith 82000.00 3
Henry Moore 92000.00 4

Employees earning more than their department’s average.

How it works:

  • For each employee, subquery calculates AVG salary in THEIR department
  • Runs N times (once per employee)

EXISTS with Correlation

EXISTS demystified: WHERE EXISTS (subquery) returns TRUE if the subquery finds at least one row, FALSE if it finds none. The SELECT 1 inside is just a placeholder — you don’t care what it returns, only whether any row matched.

1
2
3
4
Think of it as: "Does a match exist? Yes/No?"
SELECT 1 FROM ... WHERE ...
   → found rows  → EXISTS = TRUE  → include this outer row
   → no rows     → EXISTS = FALSE → exclude this outer row

Find employees assigned to at least one project:

1
2
3
4
5
6
7
8
9
10
11
SELECT 
    first_name,
    last_name,
    employee_id
FROM employees e
WHERE EXISTS (
    SELECT 1
    FROM employee_projects ep
    WHERE ep.employee_id = e.employee_id  -- Correlated!
)
ORDER BY last_name;

Expected output:

first_name last_name employee_id
Jack Anderson 9
David Brown 4
Sam Clark 7
Carol Williams 3

Employees with at least one project.

Why EXISTS? Faster than IN for existence checks — stops at first match.


Going Deeper

NOT EXISTS (Find Missing Relationships)

Find employees NOT assigned to any project:

1
2
3
4
5
6
7
8
9
10
11
SELECT 
    first_name,
    last_name,
    employee_id
FROM employees e
WHERE NOT EXISTS (
    SELECT 1
    FROM employee_projects ep
    WHERE ep.employee_id = e.employee_id
)
ORDER BY last_name;

Expected output:

first_name last_name employee_id
Leo Jackson 15
Quinn Martinez 17
Mia White 18

Employees without project assignments.

Correlated Subquery in SELECT

Show each employee with their department’s highest salary:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SELECT 
    first_name,
    last_name,
    salary,
    department_id,
    (SELECT MAX(salary)
     FROM employees e2
     WHERE e2.department_id = e.department_id
    ) AS dept_max_salary,
    CASE 
        WHEN salary = (SELECT MAX(salary)
                       FROM employees e2
                       WHERE e2.department_id = e.department_id)
        THEN 'Top Earner'
        ELSE 'Not Top'
    END AS is_top_earner
FROM employees e
ORDER BY department_id, salary DESC;

Expected output:

first_name last_name salary department_id dept_max_salary is_top_earner
Sam Clark 95000.00 1 95000.00 Top Earner
Paul Garcia 87000.00 1 95000.00 Not Top
Alice Johnson 75000.00 1 95000.00 Not Top
Frank Miller 93000.00 2 93000.00 Top Earner

Identifies top earner in each department.

Find “Second Highest” Per Group

Find employees with the 2nd highest salary in their department:

1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT 
    first_name,
    last_name,
    salary,
    department_id
FROM employees e
WHERE 2 = (
    SELECT COUNT(DISTINCT salary)
    FROM employees e2
    WHERE e2.department_id = e.department_id
      AND e2.salary >= e.salary
)
ORDER BY department_id;

Expected output:

first_name last_name salary department_id
Paul Garcia 87000.00 1

How it works:

  • For each employee, counts how many distinct salaries in their dept are >= their salary
  • If count = 2, they’re the 2nd highest

Update with Correlated Subquery

Give raises to employees earning below their department average:

1
2
3
4
5
6
7
UPDATE employees e
SET salary = salary * 1.05
WHERE salary < (
    SELECT AVG(salary)
    FROM employees e2
    WHERE e2.department_id = e.department_id
);

What happens: Each employee below their dept average gets a 5% raise.

Check results:

1
2
3
SELECT first_name, salary, department_id
FROM employees
ORDER BY department_id, salary;

Pause and Predict: Why does EXISTS typically perform better than IN for large datasets?

Answer **EXISTS stops at the first match!** - `EXISTS` returns TRUE as soon as it finds one row → stops searching - `IN` retrieves ALL matching values, then checks membership → processes everything For "does at least one exist?" questions, EXISTS is much faster on large tables.

Watch Out — Common Mistakes

Mistake #1: Forgetting Correlation (Makes Subquery Too Broad)

1
2
3
4
5
--  WRONG (not correlated properly)
SELECT first_name, salary
FROM employees e
WHERE salary > (SELECT AVG(salary) FROM employees);
-- This compares to COMPANY average, not department average

Fix: Add correlation:

1
2
3
4
5
6
-- • CORRECT (department average)
SELECT first_name, salary
FROM employees e
WHERE salary > (SELECT AVG(salary) 
                FROM employees e2 
                WHERE e2.department_id = e.department_id);

Mistake #2: Self-Including in Aggregate

1
2
3
4
5
6
7
--  LOGIC ERROR (includes self in average)
SELECT first_name, salary
FROM employees e
WHERE salary > (SELECT AVG(salary) 
                FROM employees e2 
                WHERE e2.department_id = e.department_id);
-- Compares to average INCLUDING themselves

Often not an issue, but to exclude self:

1
2
3
4
5
6
7
-- • EXCLUDE SELF
SELECT first_name, salary
FROM employees e
WHERE salary > (SELECT AVG(salary) 
                FROM employees e2 
                WHERE e2.department_id = e.department_id
                  AND e2.employee_id != e.employee_id);

Mistake #3: Performance Issues on Large Tables

1
2
3
4
5
--  SLOW (runs subquery N times)
SELECT 
    first_name,
    (SELECT COUNT(*) FROM employee_projects ep WHERE ep.employee_id = e.employee_id) AS project_count
FROM employees e;

For 10,000 employees, this runs 10,000 subqueries!

Better (use JOIN):

1
2
3
4
5
6
7
-- • FASTER
SELECT 
    e.first_name,
    COUNT(ep.project_id) AS project_count
FROM employees e
LEFT JOIN employee_projects ep ON e.employee_id = ep.employee_id
GROUP BY e.employee_id, e.first_name;

Edge Case Spotlight

Correlated DELETE

Delete employees who earn less than 80% of their department’s average:

1
2
3
4
5
6
7
8
9
10
DELETE FROM employees
WHERE employee_id IN (
    SELECT e.employee_id
    FROM employees e
    WHERE e.salary < (
        SELECT AVG(e2.salary) * 0.8
        FROM employees e2
        WHERE e2.department_id = e.department_id
    )
);

Dangerous! Always test with SELECT first:

1
2
3
4
5
6
7
SELECT employee_id, first_name, salary
FROM employees e
WHERE e.salary < (
    SELECT AVG(e2.salary) * 0.8
    FROM employees e2
    WHERE e2.department_id = e.department_id
);

Correlated Subquery with HAVING

Find departments where at least one employee earns more than company average:

1
2
3
4
5
6
7
8
9
10
11
12
SELECT 
    d.department_name,
    COUNT(e.employee_id) AS employee_count
FROM departments d
JOIN employees e ON d.department_id = e.department_id
GROUP BY d.department_id, d.department_name
HAVING EXISTS (
    SELECT 1
    FROM employees e2
    WHERE e2.department_id = d.department_id
      AND e2.salary > (SELECT AVG(salary) FROM employees)
);

Try This

Exercise 1 (Guided)

Find employees who have been assigned to MORE projects than the average for their department. Show first_name, last_name, department_id, and their project_count.

Hint Join employees with employee_projects, GROUP BY, then use HAVING with a correlated subquery that calculates average project count for the department.

Exercise 2 (Independent)

Find the highest-paid employee in each department using EXISTS. Show first_name, last_name, salary, and department_id.

Technique: An employee is the highest paid if NOT EXISTS(another employee in same dept with higher salary).

Hint WHERE NOT EXISTS (SELECT 1 FROM employees e2 WHERE e2.department_id = e.department_id AND e2.salary > e.salary)

Exercise 3 (Challenge)

Find “solo projects” — projects where only one employee is assigned. Show project_name and the employee’s first_name and last_name.

Hint Use a correlated subquery to count employees per project: WHERE (SELECT COUNT(*) FROM employee_projects WHERE project_id = p.project_id) = 1

Answer Key

Exercise 1 Answer ```sql SELECT e.first_name, e.last_name, e.department_id, COUNT(ep.project_id) AS project_count FROM employees e JOIN employee_projects ep ON e.employee_id = ep.employee_id GROUP BY e.employee_id, e.first_name, e.last_name, e.department_id HAVING COUNT(ep.project_id) > ( SELECT AVG(proj_counts.cnt) FROM ( SELECT e2.department_id, COUNT(ep2.project_id) AS cnt FROM employees e2 JOIN employee_projects ep2 ON e2.employee_id = ep2.employee_id WHERE e2.department_id = e.department_id GROUP BY e2.employee_id ) AS proj_counts ) ORDER BY project_count DESC; ``` **Expected output:** | first_name | last_name | department_id | project_count | |------------|-----------|---------------|---------------| | Alice | Johnson | 1 | 4 | | Jack | Anderson | 5 | 2 | Employees with above-department-average project loads.
Exercise 2 Answer ```sql SELECT first_name, last_name, salary, department_id FROM employees e WHERE NOT EXISTS ( SELECT 1 FROM employees e2 WHERE e2.department_id = e.department_id AND e2.salary > e.salary ) ORDER BY department_id; ``` **Expected output:** | first_name | last_name | salary | department_id | |------------|-----------|---------|---------------| | Sam | Clark | 95000.00 | 1 | | Frank | Miller | 93000.00 | 2 | | Bob | Smith | 82000.00 | 3 | | Henry | Moore | 92000.00 | 4 | | ... | ... | ... | ... | Top earner from each department. **How it works:** For each employee, checks if anyone else in their dept earns more. If not, they're the highest paid.
Exercise 3 Answer ```sql SELECT p.project_name, e.first_name, e.last_name FROM projects p JOIN employee_projects ep ON p.project_id = ep.project_id JOIN employees e ON ep.employee_id = e.employee_id WHERE ( SELECT COUNT(*) FROM employee_projects ep2 WHERE ep2.project_id = p.project_id ) = 1 ORDER BY p.project_name; ``` **Expected output:** | project_name | first_name | last_name | |--------------|------------|-----------| | Marketing Campaign | Quinn | Rodriguez | Projects with only one assigned employee.

Quick Recap

Correlated subqueries reference outer query columns
• Run once per row (unlike regular subqueries)
• Use for row-by-row comparisons to aggregates
EXISTS/NOT EXISTS excellent for “does relationship exist?” questions
• Often slower than JOINs — use when logic demands it
• Can appear in SELECT, WHERE, HAVING
• Useful for “top N per group”, comparisons within groups
• Always test with SELECT before using in UPDATE/DELETE


Part 2 Complete!

Congratulations! You’ve mastered intermediate SQL techniques:

  • SUBSTRING and CASE WHEN for data transformation
  • Self joins for hierarchical data
  • Multiple joins across complex schemas
  • COALESCE for NULL handling
  • Nested and correlated subqueries

You’re ready for advanced topics!


Up Next

Time for a Challenge!Mini Challenge 9

You’ve completed Part 2! Test your mastery of subqueries before moving to Part 3.

This post is licensed under CC BY 4.0 by the author.