Try to write this query in your head: "list employees who earn more than the average salary." You start with WHERE salary > ... — and hit a wall. Greater than what? You don't know the average; it lives inside the data itself. You'd naturally do it in two steps: first find the average, then use it. SQL lets you write both steps as one query inside another — a subquery. This lesson takes you from that first simple nesting all the way to the hardest classic in SQL interviews.
What is a subquery?
A subquery (inner query, nested query — same thing) is a SELECT written inside another statement, in brackets. The engine runs the inside first, and the outside uses its result:
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);Result
Alice | 55000 Carol | 62000 Eve | 70000
Inner query first: company average = 53166.67. Then the outer query filters against that number.
Scalar subqueries — exactly one value, or trouble
The subquery above is a scalar subquery: it's expected to return exactly one row, one column — a single value — so it can stand anywhere a value can. Two edge cases you must know:
- It returns two rows? Runtime error — MySQL says "subquery returns more than 1 row".
- It returns zero rows? No error — it quietly becomes
NULL, the comparison becomes UNKNOWN, and rows silently vanish. The sneakier bug of the two.
Scalar subqueries also work in the SELECT list — handy for attaching a company-wide figure to every row:
SELECT name, salary,
salary - (SELECT AVG(salary) FROM employees) AS diff_from_avg
FROM employees;Multi-row subqueries — IN
When the inner query returns a list, compare with IN. "Employees who work in a Bangalore-located department":
SELECT name
FROM employees
WHERE dept_id IN (
SELECT dept_id FROM departments WHERE location = 'Bangalore'
);Read inside-out: the inner query builds the list of Bangalore department ids; the outer keeps employees whose dept_id appears in that list. ANY and ALL are the comparison cousins (salary > ALL (…)), worth recognising even if you rarely write them.
Subqueries in FROM — a temporary table you invent
A subquery in the FROM clause acts like a table that exists only for this query (a derived table — it must be given an alias). This is how you aggregate first and join later — the clean fix for lesson 2's fan-out trap:
SELECT c.customer_name, t.orders_count
FROM customers c
JOIN (
SELECT customer_id, COUNT(*) AS orders_count
FROM orders
GROUP BY customer_id
) t ON t.customer_id = c.customer_id;Correlated subqueries — when inner peeks outside
Now make the opening question harder: "employees who earn more than the average of their own department." Can the inner query run once and hand back one number? No — Engineering's average and Sales' average differ. The inner query must know whose row we're currently checking:
SELECT e.name, e.dept_id, e.salary
FROM employees e
WHERE e.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.dept_id = e.dept_id -- ← the correlation
);See e.dept_id inside the bracket? The inner query is reaching out and touching the outer query's current row — that makes it correlated, and conceptually it re-runs for every outer row. It's comparing your marks to your own class's average instead of the whole school's.
The performance half of the story: naively, 1 lakh outer rows means 1 lakh inner executions — which is why correlated subqueries have a scary reputation. In practice, modern optimizers usually rewrite them into joins. In an interview, say both halves — naive cost and optimizer rescue. That answer sounds senior.
EXISTS — knock on the door, don't inventory the house
Sometimes you don't need any values from the other table — only whether a match exists. Has this customer ever placed an order? You don't need the orders; you need a yes or no.
-- customers who HAVE ordered
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
-- customers who have NEVER ordered
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);EXISTS is a pure boolean test: it never looks at the selected values (that's why SELECT 1 is the convention) and can stop at the first matching row. Notice NOT EXISTS gives you the anti-join from lesson 2 written a second way — interviewers love asking for both.
Wait — the NOT IN trap that empties your result
The most famous subquery gotcha in SQL, and it's cruel because it fails silently. Suppose you find departments with no employees:
SELECT dept_name
FROM departments
WHERE dept_id NOT IN (SELECT dept_id FROM employees);
-- if ANY employee has dept_id NULL → zero rows. Always.Why? NOT IN expands into inequality checks: x <> 10 AND x <> 20 AND x <> NULL. That last comparison is UNKNOWN (remember NULL from lesson 1?), and an AND-chain containing UNKNOWN can never become fully TRUE — so the entire query returns zero rows. No error, no warning, just an empty report at 6 pm before a demo.
The safe rewrite they want to hear: NOT EXISTS with a correlated subquery — it checks row existence per outer row, so NULLs in the data can't poison the logic. Explaining why (three-valued logic) is what upgrades your answer from correct to impressive.
The classic: second highest salary
If SQL selection rounds had one national anthem, it's this. Without window functions, the clean trick is a nested subquery:
SELECT MAX(salary) AS second_highest_salary
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);Result
second_highest_salary | 85000
Read inside-out: the inner MAX finds the top (90000); the outer MAX finds the best among everyone below it.
Asked for the Nth highest? This pattern nests deeper and gets ugly — which is exactly the moment to mention window functions as the cleaner tool. Knowing when a technique runs out is as impressive as knowing the technique.
Common mistakes
NOT INover a subquery that can return NULL — useNOT EXISTS(or filterIS NOT NULLinside).- Expecting a scalar subquery to error on zero rows — it returns NULL and your rows silently vanish instead.
- Forgetting the alias on a derived table —
FROM (SELECT …)needs a name, even if you never use it. - Writing a correlated subquery when a plain one works — if the inner query doesn't reference the outer row, don't correlate it.
- Using
SELECT *insideEXISTS— harmless but sloppy; the convention isSELECT 1.
Quick recap
| Shape | Returns | Watch out |
|---|---|---|
| Scalar subquery | one value | 2 rows = error, 0 rows = NULL |
IN (subquery) | membership in a list | fine for positive checks |
NOT IN (subquery) | absence from a list | one NULL in the list → zero rows |
| Derived table (FROM) | a temporary table | must have an alias; aggregate-then-join |
| Correlated subquery | re-evaluated per outer row | naive cost × rows; optimizers often rescue |
EXISTS / NOT EXISTS | yes/no per outer row | NULL-safe — the fix for NOT IN |
Practice Zone — PYQs from real selection rounds
Four write-the-query challenges, easy to genuinely hard — the last one (relational division: "customers who bought every Essential") is a pattern most working engineers can't write cold. Attempt it anyway; the walkthrough is in the solution.
What makes a subquery a correlated subquery?
Asked in


WHERE dept_id NOT IN (SELECT dept_id FROM employees) — and the subquery's results happen to include a NULL. What does the outer query return?
Asked in


What does EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id) actually check?
Asked in


A scalar subquery is used where one single value is expected — e.g. WHERE salary > (SELECT AVG(salary) ...). What happens at runtime if it returns two rows?
Asked in


The subquery under my NOT IN can return NULLs. What's the standard safe rewrite?
Asked in


On a naive engine, how is a correlated subquery over 1,00,000 outer rows executed?
Asked in


Given the employees table below (emp_id, name, dept_id, salary), write a query using a non-correlated subquery to list the names and salaries of employees who earn more than the average salary across the entire company.
Asked in


employees6 rows
| emp_id | name | dept_id | salary |
|---|---|---|---|
| 1 | Alice | 10 | 55000 |
| 2 | Bob | 10 | 48000 |
| 3 | Carol | 20 | 62000 |
| 4 | David | 20 | 45000 |
| 5 | Eve | 30 | 70000 |
| 6 | Frank | 30 | 39000 |
Using the same employees table, write a query using a correlated subquery to find employees who earn more than the average salary of their own department.
Asked in


employees6 rows
| emp_id | name | dept_id | salary |
|---|---|---|---|
| 1 | Alice | 10 | 55000 |
| 2 | Bob | 10 | 48000 |
| 3 | Carol | 20 | 62000 |
| 4 | David | 20 | 45000 |
| 5 | Eve | 30 | 70000 |
| 6 | Frank | 30 | 39000 |
Given an employees table with emp_id, name and salary, write a query using a nested subquery (no window functions) to find the second highest distinct salary.
Asked in


employees4 rows
| emp_id | name | salary |
|---|---|---|
| 1 | Alice | 90000 |
| 2 | Bob | 85000 |
| 3 | Carol | 85000 |
| 4 | David | 70000 |
Given products(product_id, product_name, category), customers(customer_id, name) and order_items(customer_id, product_id), write a query using nested (correlated) subqueries to find customers who have ordered every product in the 'Essentials' category.
Asked in


products4 rows
| product_id | product_name | category |
|---|---|---|
| 1 | Notebook | Essentials |
| 2 | Pen | Essentials |
| 3 | Stapler | Essentials |
| 4 | Desk Lamp | Office Extra |
customers3 rows
| customer_id | name |
|---|---|
| 101 | Nina |
| 102 | Omar |
| 103 | Priya |
order_items9 rows
| customer_id | product_id |
|---|---|
| 101 | 1 |
| 101 | 2 |
| 101 | 3 |
| 101 | 4 |
| 102 | 1 |
| 102 | 2 |
| 103 | 1 |
| 103 | 2 |
| 103 | 3 |
FAQ
Subquery vs JOIN — which should I use?
If you need columns from both tables in the output, join. If you only need the other table to filter (a yes/no or a comparison value), a subquery or EXISTS often reads clearer. Performance-wise, modern optimizers frequently turn one into the other anyway — clarity should decide.
How deep can subqueries nest?
Practically as deep as you can read them — the relational division pattern in the Practice Zone nests two levels. Beyond that, readability collapses; that's what CTEs are for.
Is EXISTS faster than IN?
The honest answer: on modern optimizers, usually the same plan. The real difference is safety — NOT EXISTS behaves correctly with NULLs while NOT IN can return nothing. Answer correctness first, performance second.
Next lesson: ranking rows without collapsing them — Lesson 5: Window Functions →


