Using Subqueries to Solve Queries: Nested SELECTs Explained
A subquery is simply a SELECT query inside another query. It can return a single
value, a list of values, or even a complete result set that the outer query works with. The
interesting case is a correlated subquery, where the inner query depends on the current row of
the outer query and runs repeatedly. That makes correlated subqueries useful for some problems,
but it can also make them expensive on large tables. This guide covers the different types of
subqueries, where you can use them, and how IN, EXISTS,
ANY, and ALL work with them. It also looks at the performance differences
that matter when a query moves from a small dataset to a production database. All examples use
the same customer and order tables, followed by twenty-three questions commonly asked in SQL
interviews.
🎯 Key Takeaways
Subqueries nest inside WHERE, FROM, SELECT, HAVING
Scalar returns one value; IN and EXISTS handle many
Correlated subqueries reference the outer query
NOT EXISTS is NULL-safe; NOT IN is not
JOINs are usually faster than correlated subqueries
23 Interview QuestionsBeginner to AdvancedReal ExamplesUpdated Oct 7, 2026
KW
Kailas Warade
✓ Verified Author
Data Engineer · Oracle Certified · SQL & PL/SQL
Kailas works with SQL, PL/SQL, Python, and PySpark to build data pipelines.
He writes the SQL interview guides at Sankalan Data Tech, focused on
practical, real-world answers.
🏆 Oracle Certified💼 Data Engineer📝 SQL & PL/SQL Guides📍 Pune, India
A subquery is a SELECT statement written inside another query. The inner
query works out something the outer query needs, and the outer query then uses that
result. It is often a simple way to break a larger SQL problem into smaller steps.
For example, finding customers who have placed an order is straightforward with a join.
But if you want customers whose credit limit is higher than the company average, you
first need to calculate that average. A subquery can do that calculation, and the outer
query can then compare each customer's credit limit with the result.
Subqueries generally fall into two groups. An uncorrelated subquery does not depend on
the outer query, so it can usually be evaluated independently. A correlated subquery
refers to a column from the outer query, which means its result depends on the current
outer row. Correlated subqueries can be very useful, but they can also become expensive
when the outer query returns many rows. Knowing the difference helps you choose the right
approach and avoid performance problems as your data grows.
Where Subqueries Can Appear
A subquery can sit in four different places inside a SELECT statement. Each position has
its own rules about what shape the subquery's output has to be.
In the WHERE clause — filtering
SELECT customer_name
FROM customers
WHERE credit_limit > (SELECT AVG(credit_limit) FROM customers);
The subquery returns a single value and the outer query compares each row against it. This is the most common position.
In the FROM clause — a derived table
SELECT t.customer_id, t.total_spend
FROM (
SELECT customer_id, SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
) t
WHERE t.total_spend > 10000;
The subquery produces an intermediate result that the outer query treats like a real table. It must have an alias. This is the standard way to filter on an aggregated value.
In the SELECT list — a scalar subquery
SELECT c.customer_name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) AS order_count
FROM customers c;
The subquery runs once per row of the outer query and returns one value for that row. Useful for per-row lookups, but potentially slow on large tables.
In the HAVING clause — filtering groups
SELECT customer_id, SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
HAVING SUM(order_amount) > (SELECT AVG(order_amount) * 2 FROM orders);
The subquery runs once, produces a threshold, and the outer query keeps only the groups that clear it. This is how you filter groups against a value computed by another query.
The rules worth memorising
An uncorrelated subquery runs once, regardless of how many outer rows exist.
A correlated subquery runs once per outer row — a subtle difference that makes a huge performance difference.
In WHERE, = expects a scalar; IN and EXISTS accept multi-row results.
A subquery in FROM must have an alias. Every database requires it.
A scalar subquery (in SELECT or after =) must return at most one row and one column. Return more and the query fails.
The habit that catches most bugs: whenever you write a subquery, ask yourself whether it references any column from the outer query. If it does, it is correlated, and it will run once per outer row. That is usually fine on a hundred rows and terrible on a million.
The Sample Data We Use in Every Example
Same two tables as the rest of the series. Keeping the data consistent means the numbers here
match what you have already seen elsewhere in the tutorial set.
Four things in this dataset make the subquery examples interesting:
Fiona Mart has a NULL credit limit, which is what makes NOT IN break in a way that NOT EXISTS does not.
Alice Corp has three orders, Bob Traders has two — grouping and aggregation have real structure to work with.
Charlie Ltd has one big order (22,000) even though his credit limit is 75,000 — useful for comparing orders against limits.
Ethan Supplies, Fiona Mart, and George Wholesale have no orders — this is what NOT EXISTS and NOT IN are for.
Scalar Subqueries — Returning One Value
A scalar subquery returns exactly one row and one column. You use it wherever a single value
is expected — after =, >, <, or in the SELECT
list beside a column.
Comparing each row to a company-wide average
SELECT customer_name, credit_limit
FROM customers
WHERE credit_limit > (SELECT AVG(credit_limit) FROM customers);
The subquery computes the average credit limit across all customers. That average is a single
number. Then the outer query filters customers whose own credit limit exceeds it.
On our data: the average of the six non-NULL credit limits is 54,166.67. Charlie Ltd (75,000),
Ethan Supplies (55,000), and George Wholesale (80,000) exceed it. Fiona Mart does not appear
because her credit limit is NULL and NULL fails the comparison.
Scalar subquery in the SELECT list
SELECT customer_name,
credit_limit,
credit_limit - (SELECT AVG(credit_limit) FROM customers) AS difference_from_avg
FROM customers
WHERE credit_limit IS NOT NULL;
Each customer gets their credit limit, plus a computed column showing how far above or below
the company average they are. The subquery runs once and its result is used for every row.
What happens when the subquery returns more than one row
-- This fails because the subquery returns multiple rows
SELECT customer_name
FROM customers
WHERE credit_limit = (SELECT credit_limit FROM customers);
The database stops with "single-row subquery returns more than one row". The subquery was
expected to produce one value, and it produced seven. The fix is either to make the subquery
genuinely scalar (add MAX or AVG, or a WHERE that
narrows it down), or to switch to IN so the outer query can accept a list.
Scalar subquery memory aid: one value in, one value out. If what you want to
compare against is a list, you are not writing a scalar subquery — you are writing a
multi-row subquery, and it needs IN or EXISTS. The error message is
the fastest way to remind yourself.
Multi-Row Subqueries — IN, ANY, ALL
A multi-row subquery returns more than one value, usually a list. The outer query needs an
operator that understands lists: IN, ANY, or ALL.
IN — matching against a list
SELECT customer_name
FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders WHERE order_amount > 10000
);
The subquery returns the ids of customers who have placed an order above 10,000. The outer
query keeps only those customers. On our data: Alice Corp (12000), Charlie Ltd (22000),
Diana Retail (18000). Three rows.
ANY — true if the comparison holds for at least one
SELECT customer_name, credit_limit
FROM customers
WHERE credit_limit > ANY (
SELECT order_amount FROM orders WHERE customer_id = 'C001'
);
Alice Corp's orders are 12,000, 7,500, and 9,000. > ANY means greater than at
least one of them — that is, greater than the smallest. So this returns customers whose
credit limit exceeds 7,500. Almost everyone qualifies.
> ANY is equivalent to > MIN(...). That is the mnemonic that
makes it stick — ANY for the easiest to beat.
ALL — true only if the comparison holds for every row
SELECT customer_name, credit_limit
FROM customers
WHERE credit_limit > ALL (
SELECT order_amount FROM orders WHERE customer_id = 'C001'
);
Now the condition is greater than every one of Alice Corp's orders — that is, greater than
the largest, 12,000. Customers with a credit limit above 12,000 qualify. Bob Traders (25,000),
Charlie Ltd (75,000), Diana Retail (40,000), Ethan Supplies (55,000), and George Wholesale
(80,000). Alice Corp at 50,000 also qualifies.
> ALL is equivalent to > MAX(...). ALL means beating the
hardest one.
NOT IN and the NULL trap
-- Dangerous: returns 0 rows if the subquery returns any NULL
SELECT customer_name
FROM customers
WHERE customer_id NOT IN (
SELECT customer_id FROM orders
);
This looks like "customers who have not ordered." It returns zero rows on our data, because
the orders subquery contains no NULLs — but the moment a NULL enters the picture (say a
staging table with a NULL customer_id), the entire query silently returns nothing. The
comparison against NULL evaluates to unknown, which fails the NOT IN condition for every row.
The safer equivalent is NOT EXISTS:
SELECT c.customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
This returns Ethan Supplies, Fiona Mart, and George Wholesale — the three customers with no
orders. It is NULL-safe because EXISTS only checks whether rows exist, not what
their values are.
The NOT IN trap in one sentence: if the subquery inside a
NOT IN can return NULL, the whole condition becomes unreliable — sometimes it
returns nothing, sometimes it behaves as you expected, and the cause is invisible. Always
use NOT EXISTS when the subquery selects a nullable column.
Correlated Subqueries
A correlated subquery references a column from the outer query. The reference is what makes
it correlated — the subquery cannot run on its own because it depends on the current row of
the outer query. The database has to evaluate it once per outer row.
Finding customers whose credit limit exceeds their own order average
SELECT c.customer_name, c.credit_limit
FROM customers c
WHERE c.credit_limit > (
SELECT AVG(o.order_amount)
FROM orders o
WHERE o.customer_id = c.customer_id
);
The inner query filters orders by o.customer_id = c.customer_id — that is the
correlation. For each customer in the outer query, the inner query runs and computes their
average order value. The outer query then compares the customer's credit limit against that
average.
On our data: Alice Corp's average order is (12000 + 7500 + 9000) / 3 = 9,500. Her credit
limit is 50,000 — she qualifies. Bob Traders' average is (4500 + 3200) / 2 = 3,850. His
credit limit is 25,000 — he qualifies. Charlie Ltd's average is 22,000, and his limit is
75,000 — he qualifies. Diana Retail's average is 18,000, and her limit is 40,000 — she
qualifies. Customers with no orders are not in the result because their subquery returns
NULL (average of no rows) and NULL fails the comparison.
When correlated subqueries shine
The pattern "compare each row to something computed from the rows that belong to it" is
exactly what correlated subqueries are for. Per-department averages. Per-customer totals.
Per-product sales. Any comparison that needs to see the current row's context.
When they hurt
The database evaluates the subquery once per outer row. On a table with a million customers,
that is a million subquery executions. Even if each one is fast — say a millisecond — the
total adds up to over sixteen minutes.
The fix is almost always to convert to a join against a grouped subquery in the FROM
clause:
SELECT c.customer_name, c.credit_limit, t.avg_order
FROM customers c
LEFT JOIN (
SELECT customer_id, AVG(order_amount) AS avg_order
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id
WHERE c.credit_limit > t.avg_order;
Same result, but the subquery runs once — computing every customer's average in a single
pass. On large tables, this difference is not subtle. It is the difference between a query
that returns in a second and one that never finishes.
From a live tuning session: a report that took forty minutes to run was
rewritten from a correlated subquery to a join against a grouped subquery. Same output, same
data, same server. The runtime dropped to under two seconds. The difference was that the
original ran the aggregation once per customer, and the rewrite ran it once for the whole
table. Correlated subqueries are one of the most common performance problems in SQL that
look perfectly reasonable on paper.
EXISTS and NOT EXISTS
EXISTS checks whether a subquery returns at least one row. It does not care what
the row contains — only whether it is there. That makes it a clean way to ask "does this
relationship exist?"
EXISTS — customers who have ordered
SELECT c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
Four customers come back — Alice Corp, Bob Traders, Charlie Ltd, Diana Retail. The three
with no orders are excluded. The SELECT 1 inside the subquery is a convention;
the value does not matter because EXISTS only checks row presence.
NOT EXISTS — customers who have not ordered
SELECT c.customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
Ethan Supplies, Fiona Mart, George Wholesale. The three customers with no orders, in one
query. This is the standard pattern for finding missing relationships, and it is the one to
reach for whenever the alternative would be NOT IN on a nullable column.
Why EXISTS is usually faster than IN
EXISTS short-circuits. The moment the subquery finds one matching row, it stops
looking. For a customer with one order, the subquery reads exactly one row and returns true.
For a customer with a million orders, it still reads one row and stops.
IN does not short-circuit in the same way. Depending on the database and the
plan, the subquery may run to completion before the comparison starts. On large tables with
many matches, EXISTS is usually the faster choice.
The optimisation is not free — you still need an index on the join column. An
EXISTS against an unindexed column is a full table scan per outer row, which
is worse than an IN against the same data. The performance difference between
the two exists only when both are set up properly.
Derived Tables — Subqueries in FROM
A subquery in the FROM clause produces what the SQL standard calls a derived
table. It runs first, its output is treated like a real table, and the outer query joins to
it or filters it like any other table.
Joining to an aggregated result
SELECT c.customer_name,
t.order_count,
t.total_spend
FROM customers c
LEFT JOIN (
SELECT customer_id,
COUNT(*) AS order_count,
SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id;
The inner query aggregates orders by customer. The outer query joins that result to the
customers table. Every customer appears exactly once, with their order count and total
spend next to them — or NULL if they have no orders.
This is the standard way to combine an aggregated result with the detail table that owns
the entity. You cannot do it with a single-level GROUP BY query, because grouping by
customer_id would collapse the customer name into the group and lose the association.
Filtering on an aggregate
SELECT t.customer_id, t.total_spend
FROM (
SELECT customer_id, SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
) t
WHERE t.total_spend > 10000;
Same idea, different use. The inner query aggregates, the outer query filters on the
aggregated value. This is an alternative to HAVING — same result, different
shape.
The alias is required
-- Fails: "subquery in FROM must have an alias"
SELECT * FROM (SELECT customer_id FROM orders);
-- Works
SELECT * FROM (SELECT customer_id FROM orders) t;
Every database requires the alias. It is a small syntactic rule that trips up beginners, and
the error message is usually clear about the fix. Give the derived table a short name —
t, agg, counts — and move on.
Chaining derived tables
SELECT c.customer_name, s.total_spend, s.rank_in_region
FROM customers c
JOIN (
SELECT customer_id,
total_spend,
RANK() OVER (PARTITION BY region ORDER BY total_spend DESC) AS rank_in_region
FROM (
SELECT c.customer_id, c.region, SUM(o.order_amount) AS total_spend
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.region
) inner_q
) s ON c.customer_id = s.customer_id
WHERE s.rank_in_region = 1;
Two derived tables nested. The inner one computes total spend per customer. The middle one
ranks customers within their region. The outer query keeps only the top-ranked customers
per region — one row per region, the biggest spender.
Chained derived tables are how complex reports stay readable. Each level does one job, and
the whole query reads top-down like a set of instructions rather than a wall of SQL.
Subqueries in the SELECT List
A scalar subquery in the SELECT list runs once per row of the outer query, returning one
value for each. It is how you attach a per-row computation without a join.
A per-row count
SELECT c.customer_name,
c.region,
(SELECT COUNT(*)
FROM orders o
WHERE o.customer_id = c.customer_id) AS order_count
FROM customers c;
Every customer appears with their order count. The subquery is correlated —
o.customer_id = c.customer_id links it to the current outer row. For each of the
seven customers, the subquery runs once and returns a number.
On our data: Alice Corp 3, Bob Traders 2, Charlie Ltd 1, Diana Retail 1, and 0 for the
remaining three.
Why it can be slow
Seven customers means seven subquery executions. A million customers means a million. Each
one reads from the orders table to count matches. Even with an index on
orders.customer_id, a million index lookups is a lot of work.
The rewrite is a LEFT JOIN against a grouped subquery:
SELECT c.customer_name,
c.region,
COALESCE(t.order_count, 0) AS order_count
FROM customers c
LEFT JOIN (
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id;
Same output. The subquery runs once for the whole table instead of once per customer. On a
table with millions of rows, the difference is dramatic — seconds versus minutes.
When scalar subqueries in SELECT are still the right choice
Small tables. Reports where the outer query returns tens of rows, not millions. Ad-hoc
queries where readability beats performance. And cases where the alternative join would
produce duplicate rows that you would then have to collapse with DISTINCT or GROUP BY.
The scalar subquery reads cleanly. As long as the outer query returns a small result, it is
a perfectly good choice. The problem is when it gets copied into a report that runs against
the full table — that is where the performance cliff appears.
Subqueries in INSERT, UPDATE, DELETE
Subqueries are not limited to SELECT. They appear inside the three data-modification
statements as well, and the patterns are worth knowing.
INSERT with a subquery
INSERT INTO high_value_customers (customer_id, customer_name, total_spend)
SELECT c.customer_id,
c.customer_name,
SUM(o.order_amount)
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name
HAVING SUM(o.order_amount) > 15000;
That copies customers whose total spend exceeds 15,000 into a summary table. The
INSERT INTO ... SELECT ... form is standard across every database, and the
subquery can be as complex as any other SELECT.
UPDATE with a subquery
UPDATE customers c
SET credit_limit = (
SELECT MAX(o.order_amount)
FROM orders o
WHERE o.customer_id = c.customer_id
)
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
Each customer's credit limit is set to their largest order amount. The first subquery
computes the maximum for the current customer. The second (in the WHERE) ensures we only
update customers who actually have orders — otherwise the first subquery would set their
credit limit to NULL.
DELETE with a subquery
DELETE FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders WHERE order_status = 'Cancelled'
);
Removes customers whose orders were all cancelled — or, more accurately, removes any
customer who has at least one cancelled order. If the intent was "customers who only have
cancelled orders" the subquery would need a different shape, which is worth being precise
about in production code.
The update-with-subquery trap
-- Dangerous: sets every row to the same value
UPDATE customers
SET credit_limit = (SELECT MAX(credit_limit) FROM customers);
That subquery is not correlated — it returns a single value for the entire table, and every
row gets updated to that value. This is not always wrong, but it is often not what the
person writing the query intended. The fix is either a correlated subquery (referencing the
current row) or a WHERE clause that narrows the update.
Always run the SELECT first. Before you run any UPDATE or DELETE that
contains a subquery, run the same query as a SELECT and check the rows it would affect. A
missing WHERE clause or a subquery that returns NULL is the fastest way to overwrite a
column that you meant to preserve, and there is no undo.
Subqueries and NULL
NULL behaves the same way inside a subquery as it does anywhere else in SQL. It does not
equal anything — not even another NULL. That single fact produces most of the surprising
subquery behaviour you will run into.
NOT IN and NULL
SELECT customer_name
FROM customers
WHERE customer_id NOT IN (SELECT customer_id FROM orders);
If the subquery returns a NULL — say a staging table has an order with a NULL customer_id —
the entire NOT IN evaluates as unknown for every row, and the query returns
nothing. Not a syntax error, not a warning. Silent wrongness.
NOT EXISTS does not have this problem because it only checks whether a matching
row exists. A NULL customer_id in the orders table simply does not match any outer
customer_id, so those rows are ignored, and the result is correct.
Scalar subqueries that return NULL
SELECT customer_name, credit_limit
FROM customers
WHERE credit_limit > (SELECT AVG(credit_limit) FROM customers);
The subquery returns the average, which is a single number. Fiona Mart does not appear
because her own credit limit is NULL and NULL fails the > comparison. This
is correct behaviour — the query filters on a comparison, and NULL is not greater than
anything.
If you want to include customers with NULL limits (say, treating them as zero), you have to
be explicit:
SELECT customer_name, credit_limit
FROM customers
WHERE COALESCE(credit_limit, 0) > (SELECT AVG(credit_limit) FROM customers);
Subquery returns NULL as a scalar value
SELECT customer_name,
(SELECT MAX(order_amount) FROM orders o WHERE o.customer_id = c.customer_id) AS largest_order
FROM customers c;
For customers with no orders, the subquery returns NULL — the MAX of an empty set is NULL,
not zero. That is why the output for those customers shows NULL in the
largest_order column. If you want a display value instead, wrap the subquery in
COALESCE:
SELECT customer_name,
COALESCE((SELECT MAX(order_amount) FROM orders o WHERE o.customer_id = c.customer_id), 0) AS largest_order
FROM customers c;
Performance — When Subqueries Get Slow
Subqueries are not inherently slow. What gets slow is the same subquery being evaluated
once per row of a large outer query. Four patterns cover most of the cases.
Pattern 1: correlated subqueries in a large outer query
SELECT c.customer_name
FROM customers c
WHERE c.credit_limit > (
SELECT AVG(o.order_amount) FROM orders o WHERE o.customer_id = c.customer_id
);
The subquery runs once per customer. On a table with a million customers, that is a million
subquery executions. The fix is a join against a grouped subquery:
SELECT c.customer_name
FROM customers c
JOIN (
SELECT customer_id, AVG(order_amount) AS avg_order
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id
WHERE c.credit_limit > t.avg_order;
Pattern 2: scalar subqueries in the SELECT list
SELECT c.customer_name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) AS order_count
FROM customers c;
Same problem — one subquery execution per outer row. Same fix — a LEFT JOIN against a
grouped subquery.
SELECT c.customer_name,
COALESCE(t.order_count, 0) AS order_count
FROM customers c
LEFT JOIN (
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id;
Pattern 3: IN against a large subquery
SELECT customer_name
FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders WHERE order_amount > 5000);
IN works, but on large tables EXISTS or a join is often faster.
The reason is that IN may build the full result list before comparing, while
EXISTS can stop as soon as it finds a match.
SELECT c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id AND o.order_amount > 5000
);
Pattern 4: subqueries in FROM that cannot be optimised
Derived tables are usually fine, but if the outer query filters the derived table in a way
the database cannot push down, the whole subquery materialises first. The plan will show a
"materialize" or "temp" step. In those cases, restructuring the query as a direct join may
let the optimiser push the filter deeper.
The universal fix: index the columns the subquery filters on
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
Every subquery that filters on orders.customer_id benefits from an index on that
column. Without it, each execution is a full table scan. With it, each execution is an index
lookup. On a table with a million orders, that difference dominates everything else in the
query plan.
How to know if your subquery is slow: run EXPLAIN ANALYZE (or
the equivalent) and look at the row counts and iteration counts. If you see the subquery's
node in the plan with a high execution count, it is running per outer row. That is the
pattern to fix, and the fix is almost always a join against a grouped subquery.
23 Interview Questions and Answers
These are the subquery questions that come up most often. Every answer stands on its own —
read them in any order, or work through them in sequence to build up the full picture.
/
🔍
No matching questions found
Try a keyword like EXISTS, correlated, or IN.
A subquery is a SELECT statement nested inside another SELECT. It runs first,
produces a result, and the outer query uses that result. It cannot stand on its own
— it exists to feed the outer query.
SELECT customer_name
FROM customers
WHERE credit_limit > (SELECT AVG(credit_limit) FROM customers);
The subquery computes the average credit limit across all customers. That single
number feeds the outer query, which filters customers whose own credit limit exceeds
it. On our data, the average of the six non-NULL limits is 54,166.67 — so customers
above that threshold come back.
The difference from a simple query is that a simple query answers one question end
to end. A subquery answers a smaller question that the outer query depends on. The
two combine to answer the larger question.
A scalar subquery in the SELECT list returns one value for each row of the outer
query. It usually references the outer row, which makes it correlated.
SELECT c.customer_name,
(SELECT COUNT(*)
FROM orders o
WHERE o.customer_id = c.customer_id) AS order_count
FROM customers c;
Each customer comes back with their order count beside them. The subquery is
correlated — the reference to c.customer_id ties it to the current row.
For each of the seven customers, the subquery runs once and returns a number.
On our data: Alice Corp 3, Bob Traders 2, Charlie Ltd 1, Diana Retail 1, and 0 for
the three customers with no orders.
This pattern is clean to read and useful for small tables. On large tables, the
subquery runs once per row, which can be very slow. Rewriting as a LEFT JOIN against
a grouped subquery is the standard fix and produces the same output.
A subquery in the WHERE clause produces a value or list the outer query filters
against. The operator you choose depends on what the subquery returns.
-- Scalar return: use =, >, <, etc.
SELECT customer_name
FROM customers
WHERE credit_limit = (SELECT MAX(credit_limit) FROM customers);
-- Multi-row return: use IN or EXISTS
SELECT customer_name
FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders WHERE order_amount > 10000);
The first subquery returns a single value — the highest credit limit, 80,000 — and the
outer query finds the customer who has it. The second returns a list of customer
ids, and the outer query matches against that list.
The most common mistake is using = against a subquery that returns
multiple rows. The database stops with "single-row subquery returns more than one
row", which is the fastest reminder to switch to IN.
A subquery in the FROM clause produces a derived table — an intermediate result the
outer query treats like a real table. It must have an alias.
SELECT c.customer_name, t.total_spend
FROM customers c
JOIN (
SELECT customer_id, SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id
ORDER BY t.total_spend DESC;
The inner query aggregates orders by customer. The outer query joins that result to
the customers table. Every customer appears once with their total spend next to them,
sorted highest to lowest.
This is the standard way to combine an aggregated result with detail data. Grouping
orders by customer_id in a single-level query would collapse the customer name along
with everything else. The derived table keeps the aggregation at one level and the
join at another.
On our data, Alice Corp comes back first with 28,500. Bob Traders second with 7,700.
Charlie Ltd third with 22,000 — wait, that ordering looks off. Let me re-check. Alice
28,500, Charlie 22,000, Diana 18,000, Bob 7,700. So the sorted output is Alice Corp,
Charlie Ltd, Diana Retail, Bob Traders.
A subquery in the HAVING clause filters groups after aggregation. The subquery
produces a threshold or list, and the outer query keeps only the groups that clear
it.
SELECT customer_id, SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
HAVING SUM(order_amount) > (
SELECT AVG(order_amount) * 2 FROM orders
);
The subquery computes twice the average order amount. The outer query keeps only the
customers whose total spend exceeds that threshold.
On our data: the average order amount is about 10,885. Doubling gives 21,771. Only
Alice Corp's total of 28,500 clears that bar. She is the only row in the result.
HAVING combined with a subquery is how you filter groups against a value computed by
another query. Same value every time the query runs, but computed dynamically from
the current data — which is what makes it useful in reports that need to adjust to
their own data.
A scalar subquery returns exactly one row and one column. It is the shape you use
wherever a single value is expected.
SELECT customer_name
FROM customers
WHERE credit_limit = (SELECT MAX(credit_limit) FROM customers);
That returns George Wholesale — the only customer with the highest credit limit,
80,000. The subquery produces one number; the outer query compares each row against
it.
Scalar subqueries can also sit in the SELECT list:
SELECT customer_name,
credit_limit,
(SELECT AVG(credit_limit) FROM customers) AS company_avg
FROM customers;
Every row gets the same company_avg value alongside its own credit limit. Useful for
quickly seeing which rows are above and below the average.
If the subquery ends up returning more than one row, the query fails with an error.
The fix is either to make the subquery genuinely scalar (add an aggregate function)
or to switch the comparison to IN.
Use a subquery that returns multiple rows and combine it with an operator that
understands lists: IN, EXISTS, ANY, or
ALL.
SELECT customer_name
FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders WHERE order_amount > 10000
);
The subquery returns the ids of customers who have placed an order above 10,000. The
outer query keeps only those customers.
On our data, that is Alice Corp (12000), Charlie Ltd (22000), and Diana Retail
(18000). Three rows.
The same pattern works with EXISTS, which is often faster:
SELECT c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id AND o.order_amount > 10000
);
Same output, but EXISTS can stop as soon as it finds one matching order
for a customer. On large tables, that short-circuit is what makes it the faster
choice.
When a subquery returns multiple rows, the outer query needs an operator that
accepts a list. The four options are IN, EXISTS,
ANY, and ALL.
-- IN: match any value in the list
SELECT customer_name FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders WHERE order_amount > 10000);
-- EXISTS: check whether a match exists
SELECT c.customer_name FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
-- ANY: compare against any value
SELECT customer_name FROM customers
WHERE credit_limit > ANY (SELECT order_amount FROM orders WHERE customer_id = 'C001');
-- ALL: compare against every value
SELECT customer_name FROM customers
WHERE credit_limit > ALL (SELECT order_amount FROM orders WHERE customer_id = 'C001');
IN returns rows where the value matches one of the subquery's results.
EXISTS returns rows where the subquery finds at least one match.
ANY returns rows where the comparison holds for at least one subquery
value. ALL returns rows where the comparison holds for every subquery
value.
IN is the most common but has a NULL trap. If the subquery returns a
NULL, comparisons against it become unknown, and the outer query may return nothing.
EXISTS does not have this problem because it only checks row presence.
A correlated subquery references a column from the outer query, which is what links
the two. It cannot run standalone, and the database evaluates it once per row of the
outer query.
SELECT c.customer_name, c.credit_limit
FROM customers c
WHERE c.credit_limit > (
SELECT AVG(o.order_amount)
FROM orders o
WHERE o.customer_id = c.customer_id
);
The inner query filters by o.customer_id = c.customer_id — that is the
correlation. For each customer, the inner query runs and computes their average
order value. The outer query compares the customer's credit limit against that
average.
On our data, customers with orders come back if their credit limit exceeds their own
average order: Alice Corp (50,000 > 9,500), Bob Traders (25,000 > 3,850),
Charlie Ltd (75,000 > 22,000), Diana Retail (40,000 > 18,000). Customers with
no orders are excluded because their subquery returns NULL, and NULL fails the
comparison.
Correlated subqueries are powerful for per-row comparisons but can be slow. Each
execution reads from the orders table. On a million-row customers table, that is a
million subquery executions. When performance matters, the fix is a join against a
grouped subquery in the FROM clause.
IN checks whether a value matches any value in a list the subquery
returns. It is the most intuitive of the multi-row operators.
SELECT c.customer_name
FROM customers c
WHERE c.region IN (
SELECT DISTINCT c2.region
FROM customers c2
JOIN orders o ON c2.customer_id = o.customer_id
WHERE o.order_amount > 15000
);
The subquery returns the regions where someone has placed a big order. The outer
query returns every customer in those regions — not just the ones who ordered. On
our data, Charlie Ltd's 22,000 order and Diana Retail's 18,000 order are both above
15,000, so their regions (North and West) qualify, and every customer in those
regions appears.
The DISTINCT in the subquery is not strictly necessary — IN
handles duplicates fine — but it makes the intent clearer and can help the optimizer
with smaller result sets.
The one gotcha with IN: NULL. If the subquery returns a NULL, comparisons
against it are unknown rather than false. For most IN queries this does
not matter — the outer rows that would match a real value still match. It is
NOT IN where the trap bites.
EXISTS returns true if the subquery finds at least one row. It does not
care what the row contains — only whether it exists.
SELECT c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
Every customer who has placed at least one order comes back. On our data, that is
Alice Corp, Bob Traders, Charlie Ltd, and Diana Retail — four rows. The three
customers with no orders are excluded.
The SELECT 1 inside the subquery is a convention. You could write
SELECT * or SELECT o.order_id and the result would be
identical. The database does not read the column values — it just checks whether
any row matches the WHERE condition in the subquery.
EXISTS is usually faster than IN because it can stop as
soon as it finds one matching row. For a customer with a million orders,
EXISTS reads one and returns. IN may need to evaluate the
full subquery list before it can compare.
NOT EXISTS returns true when the subquery finds no matching rows. It is
the standard pattern for finding rows that have no corresponding data in another
table.
SELECT c.customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
Every customer with no orders comes back. On our data, that is Ethan Supplies, Fiona
Mart, and George Wholesale — the three customers who have never placed an order.
The reason to prefer NOT EXISTS over NOT IN is NULL
safety. If the subquery inside a NOT IN returns even one NULL, the
whole condition evaluates to unknown for every row, and the query returns nothing.
Silent wrongness. NOT EXISTS does not have this issue because it only
checks row presence.
The performance profile is also usually better with NOT EXISTS. It can
stop as soon as it finds a match, and if no match exists, that is exactly the answer
the outer query needs.
Aggregates inside subqueries are how you compare each row against a value that
depends on the whole table, or on a group within it.
SELECT c.customer_name, c.credit_limit
FROM customers c
WHERE c.credit_limit > (
SELECT AVG(credit_limit) FROM customers
);
The subquery computes the company-wide average credit limit, and the outer query
filters customers above that average. The subquery runs once, produces a single
number, and the outer query uses it for every row.
For per-group comparisons, the subquery becomes correlated:
SELECT c.customer_name, c.credit_limit
FROM customers c
WHERE c.credit_limit > (
SELECT AVG(c2.credit_limit)
FROM customers c2
WHERE c2.region = c.region
);
Now each customer is compared against the average for their own region. The
correlation is on c2.region = c.region, so the subquery runs once per
distinct region — or once per row, depending on the optimizer.
COUNT works the same way. COUNT(*) inside a correlated
subquery gives the number of orders per customer. COUNT(DISTINCT ...)
counts unique values. Every aggregate function that works in a normal query works in
a subquery.
INSERT INTO ... SELECT ... copies rows produced by a query into a table.
The SELECT can be a full query with joins, aggregates, and subqueries of its own.
INSERT INTO high_value_customers (customer_id, customer_name, total_spend)
SELECT c.customer_id,
c.customer_name,
SUM(o.order_amount)
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name
HAVING SUM(o.order_amount) > 15000;
Every customer whose total spend exceeds 15,000 gets copied into the summary table.
On our data: Alice Corp (28,500), Charlie Ltd (22,000), and Diana Retail (18,000).
You can also embed a subquery inside the SELECT of the INSERT:
INSERT INTO top_customers (customer_id, customer_name)
SELECT customer_id, customer_name
FROM customers
WHERE credit_limit = (SELECT MAX(credit_limit) FROM customers);
That copies the one customer with the highest credit limit. Both forms are standard —
pick the one that reads more naturally for the job.
The column count and order in the INSERT INTO must match the SELECT's
output. Getting them out of order is the most common mistake, and the resulting data
corruption is invisible until someone queries the table and sees names in the amount
column.
A subquery in the SET clause provides the value to update with. It is
usually correlated, so each row gets its own computed value.
UPDATE customers c
SET credit_limit = (
SELECT MAX(o.order_amount)
FROM orders o
WHERE o.customer_id = c.customer_id
)
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
Every customer with orders gets their credit limit reset to their own largest order.
The WHERE clause is essential — without it, customers with no orders would have
their credit limit set to NULL, because the correlated subquery returns NULL when
there are no rows to aggregate.
On our data: Alice Corp's credit limit becomes 12,000 (her largest order). Bob
Traders becomes 4,500. Charlie Ltd becomes 22,000. Diana Retail becomes 18,000.
Ethan Supplies, Fiona Mart, and George Wholesale are untouched.
The mistake to avoid is writing an UPDATE without the WHERE clause when the subquery
is correlated. If you want to update every row, that is fine. But if some rows
should be excluded because the subquery would produce NULL for them, the WHERE
clause prevents an accidental NULL overwrite.
A subquery in the WHERE clause of a DELETE identifies which
rows to remove, based on a condition computed from other data.
DELETE FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders WHERE order_status = 'Cancelled'
);
That removes customers who have at least one cancelled order. Whether that is what
you actually want depends on the business rule — "customers with cancelled orders"
is different from "customers whose only orders were cancelled."
For the second interpretation, the subquery needs a different shape:
DELETE FROM customers
WHERE customer_id IN (
SELECT customer_id
FROM orders
GROUP BY customer_id
HAVING COUNT(*) = COUNT(CASE WHEN order_status = 'Cancelled' THEN 1 END)
);
That removes customers whose every order is cancelled. The two forms are easy to
confuse, and the difference is important — one deletes customers with any cancelled
order, the other deletes only the ones who have no other activity.
Before running any DELETE with a subquery, run the equivalent SELECT first and check
the rows it would affect. There is no undo for a DELETE, and the subquery has a way
of producing a different result set than the person writing it expected.
Two patterns work: NOT IN and NOT EXISTS. They both find
rows in one table that have no matching row in another, but they behave differently
when NULLs are involved.
-- NOT IN: simpler but has a NULL trap
SELECT customer_name
FROM customers
WHERE customer_id NOT IN (SELECT customer_id FROM orders);
-- NOT EXISTS: NULL-safe, usually the better choice
SELECT c.customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
Both return Ethan Supplies, Fiona Mart, and George Wholesale on our data. But the
NOT IN version would return zero rows the moment the orders table
contained a single row with a NULL customer_id. The NOT EXISTS version
would keep working correctly.
The reason is NULL semantics. NOT IN compares each outer value against
every value in the subquery's list. When one of those values is NULL, the comparison
is unknown, and unknown fails every NOT IN condition. Every outer row gets dropped.
NOT EXISTS does not compare values — it only asks whether a matching row
exists. A NULL customer_id in the orders table simply does not match any outer row,
which is the correct behaviour.
My rule: use NOT EXISTS by default, and only reach for
NOT IN when you are certain the subquery cannot return NULLs. On real
data, that certainty is rarely justified.
ANY and ALL compare a value against a list the subquery
returns. ANY succeeds if the comparison is true for at least one value.
ALL succeeds only if the comparison is true for every value.
-- Greater than at least one of Alice Corp's orders
SELECT customer_name, credit_limit
FROM customers
WHERE credit_limit > ANY (
SELECT order_amount FROM orders WHERE customer_id = 'C001'
);
Alice Corp's orders are 12,000, 7,500, and 9,000. > ANY means greater
than at least one — that is, greater than the smallest, 7,500. Most customers
qualify.
-- Greater than all of Alice Corp's orders
SELECT customer_name, credit_limit
FROM customers
WHERE credit_limit > ALL (
SELECT order_amount FROM orders WHERE customer_id = 'C001'
);
> ALL means greater than the largest, 12,000. Bob Traders (25,000),
Charlie Ltd (75,000), Diana Retail (40,000), Ethan Supplies (55,000), George
Wholesale (80,000), and Alice Corp (50,000) all qualify.
The mnemonic that makes it stick: > ANY is > MIN(...)
and > ALL is > MAX(...). ANY is easy to beat, ALL is
hard to beat.
ANY and ALL show up less often in production code than
IN and EXISTS, but they are worth knowing because they
express intent more precisely when the comparison is a range rather than equality.
Five patterns cover most subquery optimization work. They are listed roughly in
order of impact.
1. Convert correlated subqueries to joins. A correlated subquery
runs once per outer row. A join against a grouped subquery runs once for the whole
table. Same output, dramatically different cost.
-- Slow: correlated subquery
SELECT c.customer_name
FROM customers c
WHERE c.credit_limit > (SELECT AVG(order_amount) FROM orders WHERE customer_id = c.customer_id);
-- Fast: join against a grouped subquery
SELECT c.customer_name
FROM customers c
JOIN (SELECT customer_id, AVG(order_amount) AS avg_order FROM orders GROUP BY customer_id) t
ON c.customer_id = t.customer_id
WHERE c.credit_limit > t.avg_order;
2. Use EXISTS instead of IN for existence checks on large data.EXISTS can stop as soon as it finds a match. IN may
evaluate the whole subquery.
3. Use NOT EXISTS instead of NOT IN. Both for the NULL safety
reason and because the optimizer usually handles it better.
4. Push filters into the subquery. If the outer query only cares
about orders above a threshold, filter inside the subquery so it produces fewer rows
to combine.
5. Index the columns the subquery filters on. An index on
orders.customer_id turns each subquery execution from a full table scan
into an index lookup. On large tables this is often the single biggest win.
The way to confirm which of these is the problem is to run EXPLAIN
ANALYZE. If the subquery's node in the plan has a high iteration count, it is
running per outer row. If it has a "seq scan" attached, it needs an index.
A subquery in the WHERE clause of an UPDATE can enforce a
condition before the update runs. If the condition fails, no rows are updated — the
query simply returns zero rows affected.
UPDATE customers
SET credit_limit = 60000
WHERE customer_id = 'C002'
AND credit_limit < (SELECT AVG(credit_limit) FROM customers);
The update only happens if Bob Traders' credit limit is below the company average.
His limit is 25,000, and the average is about 54,166, so the condition is true and
the update runs. If the same query ran against Charlie Ltd (75,000), nothing would
happen — his limit is already above the average, so the WHERE clause fails and no
rows are updated.
This pattern is useful when you want to update conditionally based on the state of
the table. Rather than checking the condition in application code, checking it in
the SQL ensures the check and the update happen atomically — the data cannot change
between the check and the write.
The same idea works in DELETE. A DELETE with a subquery
condition only removes rows that still satisfy the condition at execution time. If a
concurrent transaction modified the rows, the delete simply affects fewer rows.
Subqueries can appear in multiple positions in the same query. A common pattern is
two subqueries in the WHERE clause combined with AND or
OR.
SELECT c.customer_name
FROM customers c
WHERE c.region = (SELECT region FROM regions WHERE region_name = 'Priority')
AND c.credit_limit > (SELECT AVG(credit_limit) FROM customers);
The first subquery produces the priority region's id. The second produces the
company-wide average credit limit. Both run once. The outer query requires both
conditions to be true.
Subqueries in different positions can also coexist:
SELECT c.customer_name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) AS order_count,
(SELECT SUM(order_amount) FROM orders o WHERE o.customer_id = c.customer_id) AS total_spend
FROM customers c
WHERE c.customer_id IN (SELECT customer_id FROM orders WHERE order_amount > 10000);
That query has a scalar subquery in the SELECT list for the count, another for the
total, and a multi-row subquery in the WHERE clause. Three subqueries in one query,
each doing its own job.
When queries have four or five subqueries, they can start to be hard to read.
Rewriting them as CTEs usually improves clarity:
WITH order_summary AS (
SELECT customer_id, COUNT(*) AS order_count, SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
)
SELECT c.customer_name, s.order_count, s.total_spend
FROM customers c
JOIN order_summary s ON c.customer_id = s.customer_id
WHERE c.customer_id IN (SELECT customer_id FROM orders WHERE order_amount > 10000);
Same result, easier to scan, and often faster because the aggregation runs once.
Subqueries and NULL interact in a few predictable ways. The trick is knowing which
pattern is safe and which one silently produces the wrong answer.
Replacing NULLs with display values. A subquery can compute a
fallback value to show when the column itself is NULL:
SELECT c.customer_name,
COALESCE(c.credit_limit,
(SELECT AVG(credit_limit) FROM customers)) AS effective_limit
FROM customers c;
For Fiona Mart, whose credit limit is NULL, the subquery provides the company
average as a substitute. For everyone else, their real limit appears.
Avoiding NOT IN when NULLs are possible. As covered earlier, the
fix is NOT EXISTS. That is the single most important NULL-related
subquery rule.
Being explicit about NULL in comparisons. If a correlated subquery
can return NULL, the outer comparison will silently exclude those rows. Use
COALESCE around the subquery, or wrap it in a CASE that handles the
NULL case:
SELECT c.customer_name
FROM customers c
WHERE COALESCE(
(SELECT AVG(order_amount) FROM orders WHERE customer_id = c.customer_id),
0
) < c.credit_limit;
Now customers with no orders are treated as having an average of zero, and their
credit limit is compared against that. Without the COALESCE, those
customers would drop out of the result.
Handling NULL in the SELECT list's scalar subquery. A scalar
subquery returns NULL when it finds no rows. Wrap it in COALESCE if
that NULL should be zero or a placeholder:
SELECT c.customer_name,
COALESCE((SELECT MAX(order_amount) FROM orders WHERE customer_id = c.customer_id), 0) AS largest_order
FROM customers c;
A correlated subquery is the standard way to compare each row of one table against
values derived from another table. The pattern shows up whenever you want per-row
comparisons that depend on the current row's context.
SELECT c.customer_name, c.credit_limit
FROM customers c
WHERE c.credit_limit > (
SELECT AVG(o.order_amount)
FROM orders o
WHERE o.customer_id = c.customer_id
);
Each customer's credit limit is compared against their own average order amount.
The correlation on o.customer_id = c.customer_id links the subquery to
the outer row.
On our data, customers with orders come back if their limit exceeds their average
order — Alice Corp, Bob Traders, Charlie Ltd, and Diana Retail. Customers with no
orders are excluded because the subquery returns NULL for them.
Two patterns worth knowing beyond this one:
Compare against the max in another table. A scalar subquery with
MAX produces the largest value in the other table, and the outer query
compares each row against it. Useful for "find the row that matches the extreme in
another table" questions.
Compare against a group of values. A subquery returning a list can
feed IN, EXISTS, ANY, or ALL for
more complex comparisons. IN checks membership.
EXISTS checks for the existence of a match.
ANY and ALL handle range comparisons against multiple
values.
When the comparison gets complex enough — multiple subqueries with different
correlations — consider whether a CTE would be clearer. A CTE names the intermediate
result, which lets you build the comparison in stages instead of nesting everything
into one query.
Mistakes I Keep Seeing in Interviews
These come up repeatedly. None of them are exotic, and every one of them is easy to avoid
once you know it is coming.
Mistake one: using NOT IN against a subquery that can return
NULL. The query runs without error, returns zero rows, and the candidate assumes the data
is empty. The fix is NOT EXISTS, which is NULL-safe.
Mistake two: assuming a correlated subquery and an uncorrelated one cost
the same. The uncorrelated version runs once. The correlated version runs once per outer
row. On a million-row table, that is the difference between a second and a minute.
Mistake three: writing a scalar subquery when the subquery returns
multiple rows. The database stops with "single-row subquery returns more than one row".
The fix is either to add an aggregate to make it scalar, or to switch the comparison to
IN.
Mistake four: forgetting the alias on a subquery in the FROM clause.
Every database requires one. Without it, the query fails at parse time, and the error
message ("subquery in FROM must have an alias") is worth recognising.
Mistake five: using a subquery in the SELECT list as the primary way to
compute per-row values on large tables. It works, but the subquery runs once per row.
Rewriting as a LEFT JOIN against a grouped subquery is usually an order of magnitude
faster.
Mistake six: forgetting that a subquery that returns no rows produces
NULL as a scalar value. If you write (SELECT MAX(...) FROM orders WHERE ...)
and there are no matching orders, the result is NULL, not zero. Wrap the subquery in
COALESCE if you want a real value.
Follow-Up Questions Interviewers Ask
Getting the first answer right is only half the interview. What usually decides the
outcome is what happens next. Here is what to prepare for on the subquery topics.
After "What is a subquery?" — expect
"What is the difference between a correlated and an uncorrelated subquery?"
"Can a subquery return more than one column?"
"Where can a subquery appear in a SELECT statement?"
"How would you rewrite a correlated subquery as a join?"
After "What is the difference between IN and EXISTS?" — expect
"Which one is faster on large tables?"
"What happens when the subquery contains NULL?"
"How does NOT EXISTS differ from NOT IN?"
"When would you prefer IN over EXISTS?"
After "How do you use ANY and ALL?" — expect
"What does > ANY equivalent to using MIN or MAX?"
"What does < ALL mean in plain English?"
"Can ANY and ALL be used with non-numeric comparisons?"
"How would you rewrite a query using ANY as a join?"
After "How do you optimize subqueries?" — expect
"Which is faster — a correlated subquery or a join?"
"How would you know if the optimizer is running the subquery once or many times?"
"What is the impact of an index on a correlated subquery?"
"When would a CTE be better than a subquery?"
For every question above, prepare a second-level answer. The candidates who pass are not
the ones who memorised the first answer — they are the ones who kept going for another
thirty seconds and said something useful about why it matters.
Quick Quiz
Three questions to check whether the topics above have landed.
1. What does a correlated subquery do that a non-correlated subquery does not?