SQL Subqueries Guide + 23 Interview Questions

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
23 Interview Questions Beginner to Advanced Real Examples Updated Oct 7, 2026
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
📤 Share:

On This Page

What a Subquery Really Is

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.

-- customers table
-- customer_id | customer_name    | region | credit_limit
-- C001        | Alice Corp       | North  | 50000
-- C002        | Bob Traders      | South  | 25000
-- C003        | Charlie Ltd      | North  | 75000
-- C004        | Diana Retail     | West   | 40000
-- C005        | Ethan Supplies   | South  | 55000
-- C006        | Fiona Mart       | West   | NULL
-- C007        | George Wholesale | North  | 80000

-- orders table
-- order_id | customer_id | order_amount | order_status
-- 1001     | C001        | 12000        | Delivered
-- 1002     | C002        | 4500         | Shipped
-- 1003     | C001        | 7500         | Delivered
-- 1004     | C003        | 22000        | Pending
-- 1005     | C002        | 3200         | Delivered
-- 1006     | C004        | 18000        | Shipped
-- 1007     | C001        | 9000         | Delivered

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?
2. Why is NOT EXISTS usually safer than NOT IN?
3. What does value > ALL (SELECT ...) mean?