Hook: A correlated subquery is SQL’s "ask this question for every row" pattern, which interviewers love because it reveals whether you understand row-by-row context instead of just memorizing syntax.
Question: What is a correlated subquery in SQL?
Answer: A correlated subquery is a subquery that references a column from the outer query, so it depends on the current outer row. That means the database must evaluate the inner query in the context of each row from the outer query. It is often used for row-specific checks like "does this customer have any orders?" or "what is this customer’s latest order date?"
Interview-Ready Answer: A correlated subquery is a subquery that uses a value from the outer query, so its result changes for each outer row. I use it when I need a per-row lookup or existence check, especially with EXISTS or a scalar lookup. One important detail is that the optimizer may rewrite it, but logically it behaves like a row-by-row evaluation.
A correlated subquery is a subquery that cannot stand alone, because it points back to the outer query. The inner query contains an outer reference (a column from the outer query), so the inner result depends on the current outer row.
EXISTS, it can stop as soon as it finds one match.NULL, and more than one row is an error in most databases.Think of it as a flashlight that follows each outer row and asks the inner table, "What do you know about this one?"
| Pattern | Uses outer row? | Best for | Common cost |
|---|---|---|---|
| Correlated subquery | Yes | Row-specific checks | Can be expensive |
| JOIN | No | Combining tables | Often efficient |
| Window function | No | Top per group | Often very efficient |
A JOIN is usually the first rewrite to consider if you just need to combine rows. A window function is often cleaner for ranking or latest-row problems. Correlated subqueries are still excellent when the logic is naturally "for each row, ask a question."
In the simplest mental model, a correlated subquery can behave like nested loops: if the outer query has N rows and the inner lookup scans M rows, the cost can drift toward O(N × M). With a useful index on the correlated column, the inner lookup may become much faster, closer to O(N log M) in practice. Many modern optimizers can decorrelate some queries into semi-joins or hash joins, but you should not assume that always happens; check the plan with EXPLAIN.
Real numbers matter: 10,000 outer rows against a 1,000,000-row inner table is fine if the inner predicate is indexed, but it can become painfully slow if every outer row forces a table scan. This is why interviewers care about correlated subqueries: the syntax is small, but the performance impact can be huge.
MAX, MIN, COUNT, or a more precise filter.NULL, which can change comparisons in subtle ways.NOT IN with NULL: many candidates confuse this with correlated logic; NOT EXISTS is often safer for anti-joins.Imagine a checkout service for a subscription app. The product team wants a dashboard that shows each customer and the date of their most recent payment attempt. A developer writes a correlated subquery to fetch the latest order date for each customer, because the question is naturally per-customer.
The bug happens when someone removes the aggregate and accidentally writes a scalar subquery that can return multiple payment rows. In PostgreSQL this becomes an error like "more than one row returned by a subquery used as an expression"; in SQL Server it looks like "Subquery returned more than 1 value." The dashboard job fails, alerts fire, and operators see an empty report even though the data is fine. The fix is to make the subquery return one row per outer row, usually with MAX, MIN, or a window function.
Symptoms: failed nightly job, missing dashboard numbers, error logs from the database layer. User impact: managers think payments are broken, even though the real issue is the query shape.
-- Correlated subquery demo: each inner query uses the current outer customer row.
-- This script is standard SQL and can be run in a fresh database session.
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
name VARCHAR(50) NOT NULL
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
amount DECIMAL(10,2) NOT NULL,
order_date DATE NOT NULL,
status VARCHAR(20) NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);
INSERT INTO customers (customer_id, name) VALUES
(1, 'Ava'),
(2, 'Ben'),
(3, 'Chloe'),
(4, 'Diego');
INSERT INTO orders (order_id, customer_id, amount, order_date, status) VALUES
(101, 1, 50.00, DATE '2024-01-10', 'PAID'),
(102, 1, 20.00, DATE '2024-02-05', 'FAILED'),
(103, 2, 35.00, DATE '2024-02-20', 'PAID'),
(104, 2, 15.00, DATE '2024-03-01', 'PAID'),
(105, 3, 40.00, DATE '2024-03-10', 'PENDING');
-- Correlated scalar subquery:
-- For each customer row, look back into orders using that customer's id.
-- MAX() guarantees one value per customer, so the query is safe.
SELECT
c.customer_id,
c.name,
(
SELECT MAX(o.order_date)
FROM orders o
WHERE o.customer_id = c.customer_id
) AS latest_order_date
FROM customers c
ORDER BY c.customer_id;
-- Correlated EXISTS subquery:
-- EXISTS stops as soon as it finds one matching row, which is why it is a common pattern.
SELECT
c.customer_id,
c.name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.status = 'PAID'
)
ORDER BY c.customer_id;
-- Edge case: customers with no orders.
-- The correlated subquery returns no rows, so the scalar result is NULL in the first query.
SELECT
c.customer_id,
c.name,
(
SELECT MAX(o.order_date)
FROM orders o
WHERE o.customer_id = c.customer_id
) AS latest_order_date
FROM customers c
WHERE c.customer_id = 4;
-- A common mistake would be this shape, which can fail if a customer has multiple orders:
-- SELECT c.customer_id,
-- (SELECT o.order_date FROM orders o WHERE o.customer_id = c.customer_id) AS bad_lookup
-- FROM customers c;
-- The fix is to make the subquery return exactly one value per outer row.
EXISTS can be very clear.EXISTS instead of IN? Use EXISTS when you care about whether at least one matching row exists, especially if the inner query may contain NULL. It also short-circuits naturally, which can be efficient.EXPLAIN rather than assuming it.SELECT, WHERE, and HAVING? Yes. The placement changes the use case: in SELECT they often compute a per-row value, and in WHERE or HAVING they often filter rows.NOT IN the same as NOT EXISTS? No. NOT IN can behave unexpectedly when the inner query contains NULL, while NOT EXISTS is usually the safer anti-match pattern.Common Mistakes:
MAX or redesign to EXISTS/JOIN.NOT IN where NOT EXISTS is safer; fix: remember that NULL can break NOT IN logic.Memory Hook: "A correlated subquery is a flashlight that follows each row." The outer row is the person holding the flashlight, and the inner query answers only for that one person.
Cheat Sheet:
EXISTS, NOT EXISTS, and per-row lookups.EXPLAIN to confirm the real plan.Practice Tasks:
NOT EXISTS query that finds customers with no paid orders.