4.4.7 · D4Databases

Exercises — Subqueries — correlated vs uncorrelated

3,367 words15 min readBack to topic

This page is a self-test ladder. Each rung is harder than the last:

  • L1 Recognition — can you spot which kind it is?
  • L2 Application — can you write the right subquery?
  • L3 Analysis — can you reason about behaviour (NULLs, cost, correctness)?
  • L4 Synthesis — can you combine ideas and rewrite between forms?
  • L5 Mastery — can you predict the optimizer / cost numbers and defend them?

Every problem has a collapsible full solution. Try first, then reveal.

Before the exercises, here is everything from the parent note you actually need, restated so you never have to leave this page.

Before the algebra in L5, the figure below shows the shape of these two execution models side by side — study it first, the rest of the page refers back to it.

Figure — Subqueries — correlated vs uncorrelated

Recall the parent's one test before you start:

A tiny shared schema is used throughout so nothing is ambiguous:


Level 1 — Recognition

Exercise 1.1

Classify as correlated or uncorrelated:

SELECT name FROM employee
WHERE  salary > (SELECT AVG(salary) FROM employee);
Recall Solution 1.1

Uncorrelated. Read the inner query alone: SELECT AVG(salary) FROM employee. It names no outer column — you could copy-paste it into a fresh editor and it would run and return one number. Because nothing changes between outer rows, it is logically evaluated once.

Exercise 1.2

Classify:

SELECT e.name FROM employee e
WHERE  e.salary > (SELECT AVG(e2.salary) FROM employee e2
                   WHERE e2.dept_id = e.dept_id);
Recall Solution 1.2

Correlated. The inner query mentions e.dept_id, and e is the outer table's alias. Run the inner query alone and the database screams "unknown column e.dept_id" — proof of correlation. The threshold (AVG per department) changes with each outer row, so it is logically evaluated once per outer row.

Exercise 1.3

Classify:

SELECT c.name FROM customer c
WHERE  EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
Recall Solution 1.3

Correlated. o.customer_id = c.id reaches out to c.id (outer). EXISTS almost always signals correlation, because its whole purpose is to ask a per-outer-row yes/no question.

Exercise 1.4

Classify:

SELECT name FROM customer
WHERE  id IN (SELECT customer_id FROM orders WHERE amount > 100);
Recall Solution 1.4

Uncorrelated. The inner query filters orders by a literal constant 100, not by any outer column. It builds one fixed list of customer_ids, then each outer id is membership-tested against that list.


Level 2 — Application

Exercise 2.1

Write a query listing employees who earn strictly above the company-wide average salary. (Uncorrelated.)

Recall Solution 2.1
SELECT name, salary
FROM   employee
WHERE  salary > (SELECT AVG(salary) FROM employee);

Why uncorrelated: the average is a single global constant for the whole table, so compute it once and compare each row against it. Strictly above means > (not >=).

Exercise 2.2

Write a query listing employees who earn above their own department's average. (Correlated.)

Recall Solution 2.2
SELECT e.name, e.salary
FROM   employee e
WHERE  e.salary > (SELECT AVG(e2.salary)
                   FROM   employee e2
                   WHERE  e2.dept_id = e.dept_id);

Why correlated: the threshold is not one number — it depends on e.dept_id, which differs per row. The inner alias e2 is a second, independent copy of employee so the inner filter can compare against the outer row's department. See Aggregate Functions GROUP BY — the key idea you need from there is that GROUP BY dept_id with AVG(salary) produces one average per department in a single pass, which is the uncorrelated alternative to this per-row recompute (used in Exercise 4.2).

Exercise 2.3

Rewrite Exercise 1.4's list-membership as a correlated EXISTS version. (Same result, different form.)

Recall Solution 2.3
SELECT c.name
FROM   customer c
WHERE  EXISTS (SELECT 1
               FROM   orders o
               WHERE  o.customer_id = c.id
                 AND  o.amount > 100);

Why this matches: "customer whose id is in the list of order-owners with amount > 100" is logically the same as "customer for whom there exists at least one such order." SELECT 1 is a convention: EXISTS cares only whether a row comes back, never about its contents, so we return a dummy 1. The key idea from EXISTS vs IN vs ANY you need here: EXISTS stops at the first matching row (short-circuits) and, unlike IN, never does an equality test against a NULL in a list — so it stays predictable (this saves you in Exercises 3.2–3.3).


Level 3 — Analysis

Exercise 3.1

The orders table has these rows:

id customer_id amount
1 10 50
2 10 200
3 20 75
4 NULL 300

Customers are id = 10, 20, 30. For the query below, which customers are returned, and does the NULL cause trouble?

SELECT name FROM customer
WHERE  id IN (SELECT customer_id FROM orders);
Recall Solution 3.1

Inner list = {10, 10, 20, NULL}.

  • id = 10 → matches 10returned.
  • id = 20 → matches 20returned.
  • id = 30 → not equal to 10 or 20. Now the NULL: 30 IN (..., NULL) evaluates 30 = NULL, which is UNKNOWN, not FALSE. But since a real match (a TRUE) was needed and none exists, and one comparison is UNKNOWN, the whole IN is UNKNOWN → treated as not-satisfied → customer 30 excluded. Returned: customers 10 and 20. Here the NULL happens not to change the answer, but it makes the truth-value murky. The one fact you need from NULL handling in SQL: any comparison with NULL yields UNKNOWN (a third truth value), and only a TRUE passes a WHERE.

Exercise 3.2

Now the NOT IN version — the NULL bites here:

SELECT name FROM customer
WHERE  id NOT IN (SELECT customer_id FROM orders);

Which customers are returned? (Same data as 3.1.)

Recall Solution 3.2

Intuitively we want customers with no order: only customer 30. But watch the logic. 30 NOT IN (10, 10, 20, NULL) means 30 <> 10 AND 30 <> 10 AND 30 <> 20 AND 30 <> NULL. The last term 30 <> NULL is UNKNOWN. TRUE AND TRUE AND TRUE AND UNKNOWN = UNKNOWN. UNKNOWN is not TRUE, so customer 30 is excluded too. Returned: none — the empty set! This is the infamous NOT IN + NULL bug.

Exercise 3.3

Fix Exercise 3.2 so it correctly returns customers with no orders. State why your fix dodges the NULL.

Recall Solution 3.3

Use a correlated NOT EXISTS:

SELECT c.name
FROM   customer c
WHERE  NOT EXISTS (SELECT 1 FROM orders o
                   WHERE o.customer_id = c.id);

Why it works: EXISTS asks "did any row come back?" — a pure yes/no. For customer 30, the inner query looks for orders with customer_id = 30; the NULL-owner row has customer_id = NULL, and NULL = 30 is UNKNOWN, so that row is filtered out and simply doesn't count as a match. No rows come back → NOT EXISTS is TRUE → customer 30 is returned. Returned: customer 30. EXISTS never does a <> NULL on the outer value the way NOT IN does, so the trap disappears.


Level 4 — Synthesis

Exercise 4.1

Rewrite the correlated EXISTS of Exercise 1.3 as an equivalent join. Explain what makes them equal, and one subtlety.

SELECT c.name FROM customer c
WHERE  EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
Recall Solution 4.1
SELECT DISTINCT c.name
FROM   customer c
JOIN   orders o ON o.customer_id = c.id;

Why equal: an inner join keeps every customer row that has at least one matching order — exactly the customers for whom the order "exists." The one fact you need from Joins — inner outer cross: an inner join emits one output row for every matching pair of left/right rows. The subtlety — you must add DISTINCT. Because the inner join emits one row per matching pair, a customer with 3 orders appears 3 times, whereas EXISTS (a semi-join, defined above) yields the customer once. DISTINCT restores that one-row-per-customer behaviour. This "check existence without multiplying rows" is exactly why optimizers implement EXISTS as a semi-join — see Query Optimizer Execution Plans.

Exercise 4.2

"Employees above their department average" (Exercise 2.2) — rewrite it without a correlated subquery, using a derived table (uncorrelated subquery in the FROM) plus a join.

Recall Solution 4.2
SELECT e.name, e.salary
FROM   employee e
JOIN   (SELECT dept_id, AVG(salary) AS avg_sal
        FROM   employee
        GROUP  BY dept_id) d
  ON   d.dept_id = e.dept_id
WHERE  e.salary > d.avg_sal;

Why this is now uncorrelated: the subquery d names no outer column — it computes all department averages once in a single GROUP BY pass (the very idea recapped in Exercise 2.2 from Aggregate Functions GROUP BY). Then a plain join attaches each employee to its department's precomputed average, and WHERE filters. We traded "re-run the average per employee" for "compute all averages once, then join" — often exactly what the optimizer does for you anyway.


Level 5 — Mastery

Exercise 5.1

Use the parent's cost model. The outer query scans rows. One inner-query evaluation costs "work units". The per-row comparison costs unit. Compute the naive cost of (a) the uncorrelated form and (b) the correlated form, and (c) the ratio of correlated to uncorrelated.

Recall Solution 5.1

(a) Uncorrelated units. (b) Correlated units. (c) Ratio . So the correlated form is naively ~197× heavier — dominated by the term, i.e. re-running the expensive inner query 10,000 times.

Exercise 5.2

Suppose the optimizer rewrites the correlated subquery of 5.1 into a hash semi-join, collapsing the repeated inner work down to a single (build the hash once) plus the same probes. What is the new cost, and what is the new ratio versus the uncorrelated form? Show the algebra of the collapse step by step.

Recall Solution 5.2

The collapse, one term at a time. Start from the naive correlated cost: The term says "build the inner result once for every one of the outer rows." But the inner result (the set of matching keys) does not depend on the outer row's identity — only the probe into it does. So the optimizer builds a hash table of that inner result exactly once: What remains is the per-row work: each of the outer rows still does one cheap probe/comparison, i.e. (unchanged). Adding the surviving pieces: Now plug in : That is identical to the uncorrelated form (Exercise 5.1a), so: Lesson: the naive 197× penalty was a property of the logical semantics, not of reality. Once the optimizer recognises that the inner set can be built once and probed per row (a semi-join, defined in Exercise 4.1), correlated performance equals uncorrelated. This is exactly the steel-man from the parent recap: don't assume slowness — read the execution plan (Query Optimizer Execution Plans).

The figure at the top of the page shows why this matters: the naive-correlated curve (burnt orange) climbs steeply with the inner cost , while the rewritten curve (plum, dashed) lies flat on top of the uncorrelated curve (teal).

Exercise 5.3

For what value of (with , ) does the naive correlated form cost exactly 100× the uncorrelated form? Solve algebraically.

Recall Solution 5.3

Set the ratio to 100: Substitute : Cross-multiply (multiply both sides by the denominator ): Expand the right side: Collect the terms on the left and the constants on the right: Divide both sides by : Check: correlated ; uncorrelated ; ratio . ✓


Score yourself

Recall How to read your result
  • Solid on L1–L2 → you can spot and write both kinds. Good baseline.
  • Solid through L3 → you understand the NULL / NOT IN / EXISTS correctness pitfalls.
  • Solid through L4 → you can convert between correlated subqueries, joins, and derived tables.
  • Solid through L5 → you reason about cost and know when the optimizer erases it. Mastery.

Connections

  • Subqueries — correlated vs uncorrelated — the parent topic these exercises drill.
  • EXISTS vs IN vs ANY — the operators behind Exercises 2.3, 3.1–3.3.
  • NULL handling in SQL — the root cause of the NOT IN bug in Exercise 3.2.
  • Joins — inner outer cross — the rewrite targets in Exercises 4.1–4.2.
  • Aggregate Functions GROUP BY — the GROUP BY derived table in Exercise 4.2.
  • Query Optimizer Execution Plans — why Exercise 5.2 collapses the correlated cost.