🗄️ Section 1 · Question #10

What is a correlated subquery

A correlated subquery is an inner SQL subquery that references one or more column values from the outer query block.


🟢 Junior Level

A correlated subquery is an inner SQL subquery that references one or more column values from the outer query block.

Because of this cross-reference, the inner subquery cannot execute as an independent, standalone query. Logically, the database engine must re-evaluate the inner subquery once for every single row processed by the outer query (conceptually identical to a nested for loop in programming).

Simple Analogy

Imagine a teacher grading a stack of 100 student exam papers. For each student’s paper, the teacher must open a separate cabinet, search through thousands of historical attendance records for that specific student, calculate a custom attendance bonus, and write it on the exam. The teacher opens and searches the cabinet 100 separate times.

-- For every customer, retrieve the amount of their most recent order
SELECT 
    c.name,
    (SELECT o.total_amount 
     FROM orders o 
     WHERE o.customer_id = c.customer_id  -- ⚠️ References the outer row 'c.customer_id'
     ORDER BY o.created_at DESC 
     LIMIT 1) AS last_order_amount
FROM customers c;

Why It Matters

While correlated subqueries are intuitive and readable, on large tables (e.g., 1,000,000 rows) an unoptimized correlated subquery executes 1,000,000 separate sub-queries, creating severe $O(N \times M)$ database performance bottlenecks.


🟡 Middle Level

Logical vs. Physical Execution

The Logical Conceptual Model: $O(N \times M)$

for outer_row in outer_table:               # N rows (e.g., 1,000,000 customers)
    result = execute_subquery(outer_row.id) # Executes 1,000,000 separate times!
    emit(outer_row, result)

Physical Decorrelation (Unnesting to Semi-Join)

Whenever possible, PostgreSQL’s cost-based query optimizer attempts to decorrelate subqueries into set-based joins:

-- Written as a Correlated EXISTS query:
SELECT * FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o 
    WHERE o.customer_id = c.customer_id AND o.total_amount > 500
);

-- Decorrelated by PostgreSQL into:
-- Plan: Hash Semi-Join (Executes in O(N + M) time via a single hash table pass!)

Identifying SubPlan vs. InitPlan in Execution Plans

When inspecting queries with EXPLAIN (ANALYZE, BUFFERS):

  1. SubPlan (The Warning Sign — Per-Row Execution): The subquery remains correlated and executes iteratively for every outer row:
    Seq Scan on customers c
      SubPlan 1 -> Aggregate -> Index Scan on orders o (customer_id = c.customer_id)
    
  2. InitPlan (Optimal — Evaluates Once): The subquery is uncorrelated, evaluated exactly once before main execution, and cached as a static parameter:
    InitPlan 1 -> Aggregate -> Seq Scan on employees  <-- Evaluates ONCE (O(1))
    Seq Scan on customers (Filter: salary > $0)
    

Optimized Alternatives to Correlated Subqueries

Scenario Anti-Pattern (Correlated Subquery) Production-Grade Pattern
Per-Row Aggregation Scalar Subquery in SELECT LEFT JOIN ... GROUP BY or Window Function
Top-N Related Rows Correlated Subquery with LIMIT 1 LEFT JOIN LATERAL or ROW_NUMBER() Window Function
Existence Filtering WHERE id IN (correlated subquery) WHERE EXISTS (...) (Compiles to Semi-Join)
Bulk Updates UPDATE ... WHERE col = (SELECT ...) UPDATE ... FROM (CTE / JOIN)

🔴 Senior Level

Deep Dive: PostgreSQL 14+ Memoize Optimization Node

In parameterized Nested Loops and LATERAL joins, PostgreSQL 14 introduced the Memoize execution node to cache subquery evaluation outputs in memory:

EXPLAIN (ANALYZE, BUFFERS)
SELECT c.name, recent_order.total_amount
FROM customers c
LEFT JOIN LATERAL (
    SELECT total_amount FROM orders o
    WHERE o.customer_id = c.customer_id
    ORDER BY created_at DESC LIMIT 1
) recent_order ON true;

-- PostgreSQL 14+ Execution Plan:
-- Nested Loop (cost=... actual rows=1000000)
--   -> Seq Scan on customers c
--   -> Memoize (Hits: 850000, Misses: 150000, Evictions: 0, Cache Size: 12MB)
--       -> Limit
--           -> Index Scan on orders o (Index Cond: customer_id = c.customer_id)

Diagnostic Metrics:

  • Cache Hit Ratio: $\frac{\text{Hits}}{\text{Hits} + \text{Misses}} = \frac{850,000}{1,000,000} = 85\%$ (85% of queries served instantly from RAM!).
  • If outer parameter values are near 100% unique (e.g., synthetic UUIDs), Hits will approach 0, adding minor hash table overhead.

High-Performance Correlated UPDATE Pattern

Running correlated subqueries inside UPDATE or DELETE statements on millions of rows is a common cause of production outages:

-- ❌ CATASTROPHIC ANTI-PATTERN: Evaluates subquery for every order (100M executions)
UPDATE orders o 
SET status = 'ARCHIVED'
WHERE created_at < (
    SELECT MIN(created_at) + INTERVAL '1 year'
    FROM customers c 
    WHERE c.customer_id = o.customer_id
);

-- ✅ OPTIMAL: Precompute threshold once via CTE and execute via UPDATE ... FROM
WITH customer_cutoffs AS (
    SELECT customer_id, MIN(created_at) + INTERVAL '1 year' AS cutoff_date
    FROM customers
    GROUP BY customer_id
)
UPDATE orders o
SET status = 'ARCHIVED'
FROM customer_cutoffs cc
WHERE o.customer_id = cc.customer_id
  AND o.created_at < cc.cutoff_date;
-- Compiles to Hash Join: 2 single passes instead of 100,000,000 subqueries!

Window Functions vs. Correlated Aggregation

-- ❌ Correlated Subquery: O(N * M) nested scan overhead
SELECT o.order_id, o.customer_id, o.total_amount,
       (SELECT AVG(total_amount) FROM orders o2 WHERE o2.customer_id = o.customer_id) AS cust_avg
FROM orders o;

-- ✅ Window Function: O(N log N) single pass with in-memory WindowAgg
SELECT 
    o.order_id, o.customer_id, o.total_amount,
    AVG(o.total_amount) OVER (PARTITION BY o.customer_id) AS cust_avg
FROM orders o;

4 Tricky Questions

1. Does writing SELECT 1 vs SELECT * vs SELECT column_name inside a correlated WHERE EXISTS (...) subquery have any performance impact in PostgreSQL?

Answer:
Zero performance difference.
The PostgreSQL SQL parser and optimizer discard the SELECT target projection list entirely in an EXISTS subquery. The executor only checks whether the inner plan node emits at least one tuple; it never evaluates, retrieves, or materializes column values from heap pages. Writing SELECT 1, SELECT *, or SELECT 1/0 produces identical compiled execution plans (even SELECT 1/0 will not trigger a division-by-zero error if a matching row exists, because the target expression is never evaluated!). SELECT 1 is preferred purely as a universal convention.

2. Why does a query like SELECT u.id, (SELECT o.id FROM orders o WHERE o.user_id = u.id ORDER BY o.created_at DESC LIMIT 1) FROM users u fail to decorrelate into a standard join, and how should it be rewritten?

Answer:
Because LIMIT 1 acts as an Optimization Fence.
Standard relational algebra joins (INNER JOIN, LEFT JOIN) cannot represent a per-row LIMIT operation in a single flat join tree. As a result, the optimizer cannot pull the subquery up into a join, forcing it to compile into a per-row SubPlan ($O(N \times M)$).
To optimize it: Rewrite using either:

  1. LEFT JOIN LATERAL:
    SELECT u.id, last_order.id
    FROM users u
    LEFT JOIN LATERAL (
        SELECT o.id FROM orders o WHERE o.user_id = u.id ORDER BY o.created_at DESC LIMIT 1
    ) last_order ON true;
    
  2. ROW_NUMBER() Window Function within a single-pass CTE.

3. Why is an UPDATE with a correlated subquery in its WHERE or SET clause often a silent production killer on tables with 10M+ rows, and how does UPDATE ... FROM solve it?

Answer:
An UPDATE with a correlated subquery (e.g., SET balance = (SELECT SUM(...) FROM ... WHERE user_id = u.id)) cannot be unnested by PostgreSQL’s DML optimizer. It executes the inner subquery repeatedly for every single row visited by the UPDATE, causing millions of random index/heap reads and holding write locks for hours.
Using UPDATE ... FROM allows PostgreSQL to evaluate the update as a set-based Hash Join or Merge Join. The subquery or aggregate runs once, builds an in-memory hash table, and streams the updates to the target relation in a single linear pass.

4. What happens when the Memoize cache in PostgreSQL 14 runs out of memory (work_mem / cache limit)?

Answer:
When the cache reaches memory limits (bounded by work_mem and internal sizing heuristics), Memoize transitions from an unbounded cache to an LRU / Clock eviction policy.
In EXPLAIN (ANALYZE, BUFFERS), you will observe the metric Evictions: <count>.
If outer parameter keys exhibit high repetition, Memoize remains effective. However, if the outer dataset consists of millions of unique keys with minimal duplicates, the cache experiences continuous evictions and misses, introducing hash table insertion/eviction overhead with zero read savings.


🎯 Interview Cheat Sheet

Core Concepts

  • Definition: A correlated subquery references columns from the outer query block and logically executes once per outer row ($O(N \times M)$).
  • Execution Plan Nodes:
    • SubPlan: Correlated per-row execution $\rightarrow$ Performance bottleneck.
    • InitPlan: Static evaluation executed once before main query ($O(1)$) $\rightarrow$ Fast.
  • Decorrelation: The optimizer converts correlated EXISTS and IN predicates into set-based Semi-Joins ($O(N + M)$).

Architectural Optimization Patterns

  • Replace Scalar Subqueries: Use LEFT JOIN ... GROUP BY or Window Functions (OVER (PARTITION BY ...)).
  • Replace Correlated Top-N: Use LEFT JOIN LATERAL or ROW_NUMBER().
  • Bulk DML Operations: Use UPDATE ... FROM instead of correlated subqueries in SET or WHERE.
  • Memoize (PG 14+): Automatically caches parameterized subquery outputs in RAM to eliminate redundant index seeks.

Red Flags (What NOT to Say)

  • ❌ “Correlated subqueries in the SELECT list perform fine as long as there is an index.” (Executing 1,000,000 index seeks for 1M rows creates a massive disk I/O and CPU bottleneck).
  • ❌ “Writing SELECT * inside EXISTS is slower than SELECT 1 because it reads all columns.” (Postgres discards the projection list inside EXISTS entirely; performance is identical).
  • ❌ “Memoize makes LATERAL joins faster in all scenarios.” (If outer parameter keys are distinct, cache hit rate is 0%, adding pure hash overhead).