Correlated Subqueries: Row-by-Row Comparisons
Concept. A correlated subquery references a column from the outer query, so it re-runs once per outer row instead of once for the whole query.
Intuition. "Users with a rating above their own average" recomputes each user's average for every row, because the comparison value depends on which user the row belongs to.
Figure 1. The difference in one picture. A regular subquery computes one global number and compares every row against it. A correlated subquery re-runs its inner query once per outer row, parameterized by that row: it measures Mickey against 4.2, Minnie against 4.4, Daffy against 3.9. The comparison value is recomputed for each row, the double-for-loop model below.
Example: Users Above Their Own Average
Figure 2. Each user is measured against their own average. The inner query re-runs filtered to that user (the highlighted correlation line), giving Mickey 4.2, Minnie 4.4, Daffy 3.9. Everyone has at least one rating above their own average, so all three are kept; Pluto has no ratings, no average, and drops.
Example: Existence Checks (EXISTS and NOT EXISTS)
Figure 3. One correlated check answers both EXISTS and NOT EXISTS. For each user the inner query asks whether any rating beats 4.5. NOT EXISTS keeps the users with none (Mickey, whose top is exactly 4.5, and Pluto, who has no listens at all); EXISTS keeps the opposite pair, Minnie and Daffy.
Mental Model: A Double For-Loop
The correlation: the inner query references a variable scoped to the outer row, so the database runs it once per outer row. The inner query has its own scope (here l2, its own copy of Listens), but it reaches out to a variable from the outer scope (u). That cross-reference is the correlation.
Figure 4. The execution model. The outer loop walks one user u at a time; the inner query re-runs each pass. The correlation is the pink link: inside the inner query, l2.user_id = u.user_id reads u from the outer scope. So the inner average is computed only over that user's rows, and the inner query runs once per outer row.
When to reach for this: whenever the comparison threshold changes per row. The clue is the word their or its own: "above their own average," "top in their genre," "better than their manager's salary." Each row supplies its own comparison value. If the threshold is a single global number, use a regular subquery; if it depends on the row, correlated.
Correlated vs Regular: Key Differences
| Aspect | Regular Subquery | Correlated Subquery |
|---|---|---|
| Execution | Runs once total | Runs once per outer row |
| Independence | Standalone query | Depends on outer query |
| Use Case | Global comparisons | Row-specific comparisons |
| Performance | Usually faster | N+1 query problem |
| Example | "Above global average" | "Above your average" |
When to Use Correlated Subqueries
Good Use Cases:
-
Personal comparisons (above your average)
-
Relative rankings (best in your category)
-
Row-specific existence checks (do you have...)
-
Self-referential queries (employees earning more than their manager)
Avoid When:
-
Simple global comparisons suffice
-
Performance is critical (consider JOIN alternatives)
-
Query complexity becomes unreadable
Common Patterns
-
Personal Aggregates: Compare to your own AVG/MAX/MIN
-
Existence per Entity: EXISTS with correlation
-
Ranking within Groups: Find top N per category
-
Self-Joins Alternative: often easier to read than the equivalent self-join.
Common Mistakes with Correlated Subqueries
Forgetting Correlation
Problem: Without correlation, EXISTS checks globally instead of per-row
Performance Anti-Pattern (N+1 Problem)
Figure 5. Running the inner query once per outer row costs 1 + N queries: 10,001 round-trips for 10,000 users, which scales linearly with the number of users and is far too slow on a large table. Modern optimizers (Postgres, MySQL, SQL Server) recognize the pattern and rewrite it as a single JOIN with a windowed aggregate, one pass over the data.
Note: Explicit JOINs are still preferred when you want clarity and guaranteed performance, rather than relying on the optimizer to spot the rewrite.