skip to content

Give the situations in which a query engine cannot flatten a correlated subquery into a join, and describe what the resulting plan does instead.

level: seniorimportance: should knowfreq 42%

answer

  1. LIMIT/ORDER inside -> per-row top-1
  2. correlation under OR -> not a conjunct
  3. non-equality / function-wrapped correlation
  4. volatile functions must run per row
  5. count bug: outer join + coalesce to 0

basics

~20 s

Flattening fails when the subquery's per-row semantics cannot be preserved by a set-oriented join: row-limiting clauses inside it, correlation under OR or in a non-equality predicate, volatile or side-effecting functions, some placements under an outer join, and correlations that reach past an aggregation. The plan then re-evaluates the subquery once per outer row.

solid answer

~1 min

Decorrelation is legal only when the join form yields identical results and multiplicity. Common blockers: - **Row-limiting or ordering inside the subquery** (`LIMIT`/`TOP`/`FETCH FIRST`, or a per-row window) - the answer depends on the outer row's own sort, which a single set-oriented join cannot reproduce. - **Correlation inside an OR** with an uncorrelated branch - the predicate is not a conjunct that can be promoted to a join condition. - **Non-equality or expression-wrapped correlation** (`WHERE s.d BETWEEN o.d - 7 AND o.d`) - flattens only to a range/theta semi-join, which some engines do not implement. - **Volatile, side-effecting or non-deterministic functions**, which must run per row. - **Correlation crossing an aggregate or a DISTINCT boundary**, where the count-bug applies: an empty inner group must yield 0 or NULL for that outer row, not a missing row. - **Subqueries on the null-supplying side of an outer join**, where NULL-extension changes semantics. The fallback plan shows the inner query as a subplan or a nested loop whose execution count equals the outer cardinality - cost approximately outer_rows x inner_cost. The remedy is to rewrite by hand: pre-aggregate and outer join, use a lateral/apply form the engine can index, or split into two statements.

code

sql · 15 lines
sql
-- often not flattened: per-outer-row ORDER BY + LIMIT
SELECT i.id,
       (SELECT p.price FROM prices p
        WHERE p.item_id = i.id
        ORDER BY p.valid_from DESC LIMIT 1) AS latest_price
FROM items i;

-- single-pass rewrite
SELECT i.id, p.price AS latest_price
FROM items i
LEFT JOIN (
  SELECT item_id, price,
         ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY valid_from DESC) AS rn
  FROM prices
) p ON p.item_id = i.id AND p.rn = 1;

go deeper

for a junior

Name one or two blockers (LIMIT inside the subquery, OR-ed correlation) and say the plan falls back to running the subquery per row.

for a middle

List several blockers with reasons, and know the pre-aggregate-plus-outer-join rewrite.

for a senior

Diagnose from the plan (execution counts), quantify the multiplier, choose between pre-aggregation, window functions and lateral probes, and explain the count bug.

for a principal

Frame it as a query-standards question: which shapes your team writes by default so plans stay stable across engine versions, and when a materialized pre-aggregate is the architectural answer.

## The legality bar A rewrite is allowed only if it returns the same rows with the same multiplicity for every possible database state - not just for today's data. That makes decorrelation a proof obligation, and several constructs make the proof impossible. ## Blocker 1: row-limiting inside the subquery `(SELECT price FROM prices p WHERE p.item_id = i.id ORDER BY p.valid_from DESC LIMIT 1)` asks for a *per-outer-row* top-1. A join produces all matching rows; recovering 'the first per group' requires a ranking or a distinct-on style operator, and whether the engine can synthesize that depends on its rewrite repertoire. Many engines simply keep the subplan and re-run it per row. Hand-written alternatives are a window function with a rank filter, or a lateral join that keeps the per-row limit but lets the engine drive it with an index. ## Blocker 2: correlation under OR Join predicates are conjuncts. `WHERE o.flag = 'Y' OR EXISTS (SELECT ...)` cannot become a join condition because rows can qualify without the subquery being consulted. Some optimizers split the query into a union of two disjoint branches; when they do not, the subquery evaluates per row. Rewriting the OR into a `UNION ALL` of mutually exclusive branches is the manual version of that transform. ## Blocker 3: non-equality and expression-wrapped correlation Equality correlations map to hash or merge joins. Range correlations (`BETWEEN o.d - 7 AND o.d`) map only to theta or range joins, which not every engine implements; an inequality correlation may flatten to a nested-loop semi-join with an index probe (fine) or not at all (bad). Wrapping the correlation column in a function - `WHERE lower(s.k) = o.k` - can also defeat both flattening and index access unless a matching expression index exists. ## Blocker 4: volatile or side-effecting expressions A subquery calling a non-deterministic function, a sequence generator, or a procedure with side effects must be executed exactly as written - the number of invocations is observable. Engines mark such expressions volatile and refuse to hoist, cache, or flatten them. ## Blocker 5: aggregates and the count bug A scalar subquery like `(SELECT COUNT(*) FROM o WHERE o.cid = c.id)` returns 0 when there are no matching rows. Turning it into an inner join against a grouped aggregate loses those outer rows entirely; turning it into a left outer join gives NULL, not 0. The correct rewrite is a left outer join against the pre-grouped aggregate with a NULL-to-0 coalesce - this asymmetry is the classic *count bug*, and it is why naive flattening of aggregate subqueries is unsound. Optimizers that implement it handle the coalesce explicitly; those that do not leave the subplan in place. ## Blocker 6: position under an outer join If the correlated subquery sits in the null-supplying side of an outer join, or references a column that may be NULL-extended, moving the predicate into a join condition changes which rows get preserved. The rewriter has to reason about NULL-extension order and often declines. ## What the fallback plan looks like You see the inner query as an attached subplan, filter subquery, or a nested-loop inner side whose actual execution/loop count equals the outer row count. Cost is outer_rows x inner_cost. Some engines cache repeated parameter values, which helps when the correlation column has few distinct values and does nothing when it is unique. ## Remedies, in order of preference 1. **Pre-aggregate and outer join**: compute the inner result grouped by the correlation key once, then left join and coalesce. This is the manual decorrelation and it is usually the biggest win. 2. **Window function**: replaces per-row top-1 and per-row ranking subqueries with a single pass. 3. **Lateral/apply with an index**: keeps per-row semantics but makes each execution a cheap index probe - acceptable when the outer side is small or already filtered. 4. **Split the statement**: materialize the inner result into a temporary table, then join. Useful when the inner query is expensive and reused. 5. **Reduce the multiplier**: if the outer side is filtered down to a few hundred rows before the subquery is evaluated, per-row execution may be perfectly fine - always check the actual multiplier before rewriting. ## Judgment The senior move is not 'never write correlated subqueries'. It is: know the shapes that block flattening, read the plan for per-row execution counts, estimate the multiplier, and rewrite only where the multiplier is large.

  • What exactly is the count bug, and how do you avoid it when decorrelating by hand?
    A scalar aggregate subquery returns 0 (for COUNT) or NULL (for SUM/MAX) when no inner rows match, but an inner join against a grouped aggregate drops those outer rows and a left join yields NULL instead of 0. You avoid it by pre-aggregating, left outer joining on the correlation key, and coalescing the aggregate to the value the scalar subquery would have produced.
  • When is leaving a correlated subquery un-flattened actually acceptable?
    When the multiplier is small - the outer side is already reduced to a few hundred rows by other predicates - and each execution is a cheap indexed probe. Then per-row execution costs microseconds and the rewrite adds complexity for no gain. Measure the actual loop count and per-execution cost in the plan before rewriting.

saying these in an interview costs you the question

  • Asserting every correlated subquery can be flattened by a good optimizer
  • Rewriting an aggregate subquery as an inner join and losing the zero-match rows
  • Ignoring the outer cardinality when judging whether per-row execution matters
  • Blaming statistics for a plan whose real problem is a non-flattenable construct
  • Assuming lateral/apply is always slow - with an index it is a cheap per-row probe

context