Module 2 — Subqueries

A query inside a query. The tool for "compare against something you have to compute first" — and the tool interviewers use to test whether you understand execution order, not just syntax.
Level 1 · Foundations

Why subqueries exist

Sometimes a question can't be answered by looking at raw table data directly — you first need to compute something (a max, an average, a list of ids, a yes/no check), and only then compare each row against that computed thing. A subquery is a SELECT statement nested inside another SQL statement, used to produce that intermediate value, list, or table on the fly.

Analogy Think of asking "who in my class scored above the class average?" You can't answer that by looking at one student at a time — you first have to compute the class average (one full pass over everyone's scores), and only then can you go back and check each student against that number. The subquery is the "first, compute the average" step; the outer query is the "now compare everyone against it" step.

What it is

A subquery is just a SELECT wrapped in parentheses, placed somewhere another SQL statement expects a value, a list, or a table. It can appear in WHERE, SELECT, FROM, or even HAVING.

When to use it

  • You need to compare a row against an aggregate (max, avg, count) computed from the same or a different table.
  • You need to check existence of a related row without pulling back any of its columns (data validation, filtering).
  • You need to build a small computed lookup table on the fly, used only within this one query.

When NOT to use it

  • When a plain JOIN says the same thing more simply and you actually need columns from the other table — joins are usually easier for a reader to follow than deeply nested subqueries.
  • When a correlated subquery would force a slow row-by-row loop (see Execution Flow below) and an equivalent JOIN or window function would run in a single pass.
🧠
Mental model: a non-correlated subquery runs once, completely independent of the outer query, and hands back a value/list/table that the outer query then uses. A correlated subquery is different — it re-runs once per row of the outer query, because it references a column from the outer row. That distinction is the single most important idea in this whole module — everything else builds on it.

Schema & sample data for this module

We reuse employees/departments from Module 1 (same data), and add a customers/orders pair for the data-quality/production examples.

DDL (recap from Module 1)

-- employees / departments: identical to Module 1 (16 employees, 6 departments, Legal has 0 employees,
-- Tanvi Desai & Sameer Gupta have NULL dept_id, Priya/Rohit(103) tie at 145000, Ananya/Vikram tie at 92000)

CREATE TABLE customers (
    customer_id   INT PRIMARY KEY,
    customer_name VARCHAR(50),
    country       VARCHAR(50)
);

CREATE TABLE orders (
    order_id     INT PRIMARY KEY,
    customer_id  INT,             -- can be a "dangling" id that doesn't exist in customers (data quality issue)
    order_date   DATE,
    total_amount DECIMAL(10,2)
);

INSERT INTO customers VALUES
(201, 'Rahul Bansal',   'India'),
(202, 'Grace Lin',      'Singapore'),
(203, 'Omar Haddad',    'UAE'),
(204, 'Laura Fischer',  'Germany');

INSERT INTO orders VALUES
(5001, 201, '2024-01-10', 2500.00),
(5002, 202, '2024-01-11', 1200.00),
(5003, 201, '2024-01-15',  800.00),
(5004, 203, '2024-02-01', 4300.00),
(5005, 999, '2024-02-05',  650.00);   -- customer_id 999 DOES NOT EXIST in customers -- orphaned order (data bug)
customers (4 rows)
customer_idcustomer_namecountry
201Rahul BansalIndia
202Grace LinSingapore
203Omar HaddadUAE
204Laura FischerGermany
orders (5 rows — one orphaned)
order_idcustomer_idtotal_amount
50012012500.00
50022021200.00
5003201800.00
50042034300.00
5005999650.00
NoticeOrder 5005 points to customer_id = 999, which doesn't exist anywhere in customers. Laura Fischer (204) has never placed an order. These two facts drive most of the subquery examples below — subqueries are the natural tool for "find the mismatch."
Level 1 · Beginner

Scalar subquery

What it is: a subquery that returns exactly one row and one column — a single value. It can be used anywhere a plain value could go: in WHERE, in SELECT, even inside arithmetic.

Why it exists: you often need to compare a row against a single computed number — the overall max, the overall average, a specific lookup value — without hardcoding that number.

AnalogyA scalar subquery is like a calculator you run once, write the single result on a sticky note, and then use that sticky note's number throughout the rest of your work.

Easy example

Find employees who earn more than the company-wide average salary.

SELECT emp_name, salary
FROM employees
WHERE salary > (
    SELECT AVG(salary) FROM employees
);

Line by line: the inner query SELECT AVG(salary) FROM employees runs first, completely on its own, and produces one number (roughly 100,687.5). The outer query then just does a plain comparison, as if you'd typed WHERE salary > 100687.5 directly.

Common mistakeIf a "scalar" subquery accidentally returns more than one row (say you forgot a WHERE filter, or used AVG without realizing a GROUP BY was implicitly needed), most databases throw a runtime error like "subquery returns more than one row" — it's not silently truncated to the first row. Always double check your subquery, run it standalone, and confirm it really returns one row before nesting it.

Scalar subquery in SELECT

SELECT
    emp_name,
    salary,
    (SELECT AVG(salary) FROM employees) AS company_avg,
    salary - (SELECT AVG(salary) FROM employees) AS diff_from_avg
FROM employees;
Production tipA scalar subquery placed in the SELECT list re-runs once per outer row in many engines unless the optimizer is smart enough to compute it once and cache it (it often is, for a non-correlated constant subquery like this one — but don't assume, check with EXPLAIN on large tables). A CTE computed once and cross-joined is a safer bet at scale — covered in Module 3.
Level 1 · Beginner

IN subquery

What it is: a subquery that returns a list of values (one column, many rows), used with IN to check membership.

AnalogyA guest list at the door. The subquery builds the guest list; IN checks "is this person's name on the list?"

Easy example

Find all employees who work in a department located in Bangalore.

SELECT emp_name
FROM employees
WHERE dept_id IN (
    SELECT dept_id FROM departments WHERE location = 'Bangalore'
);

The inner query returns (1, 4) — Engineering and HR. The outer query keeps any employee whose dept_id is in that list.

Medium example — customers who have placed at least one order

SELECT customer_name
FROM customers
WHERE customer_id IN (
    SELECT customer_id FROM orders
);
-- Rahul, Grace, Omar -- Laura is excluded, she has never ordered
Common mistakeIN silently de-duplicates nothing on its own — if the subquery returns the same value many times (e.g. customer_id appearing in multiple orders), IN still just checks membership once per outer row; it does NOT multiply your outer rows the way a JOIN would. This is actually a strength of IN over a naive JOIN + DISTINCT for pure "does it exist" questions.
Level 1 · Beginner

Subqueries in HAVING

What it is: a subquery placed inside a HAVING clause so you can compare one group's aggregate against another aggregate — something a plain WHERE can never do, because WHERE runs before grouping even happens.

WHERE vs HAVING — why this needs HAVING at all

Filter timing
ClauseRunsCan filter on
WHEREBefore GROUP BYRaw row columns only — never an aggregate
HAVINGAfter GROUP BYAggregates (AVG, SUM, COUNT...) of the group
Common mistakeWriting WHERE AVG(salary) > 100000 throws a syntax/semantic error in every mainstream database — aggregates simply don't exist yet at the point WHERE is evaluated. If you catch yourself typing an aggregate function inside WHERE, it belongs in HAVING instead.

Example — departments whose average salary beats the company average

SELECT dept_id, AVG(salary) AS dept_avg
FROM employees
GROUP BY dept_id
HAVING AVG(salary) > (
    SELECT AVG(salary) FROM employees
);
-- The inner AVG runs once, company-wide; HAVING compares every group's own AVG against it

Example — customers whose total spend beats the average customer's total spend

SELECT customer_id, SUM(total_amount) AS total_spent
FROM orders
WHERE customer_id IS NOT NULL
GROUP BY customer_id
HAVING SUM(total_amount) > (
    SELECT AVG(total_spent)
    FROM (
        SELECT SUM(total_amount) AS total_spent
        FROM orders
        WHERE customer_id IS NOT NULL
        GROUP BY customer_id
    ) per_customer
);
NoticeThis is the exact same two-level-aggregation shape as Hard Question H2 later in this module — a group's own aggregate compared against the average of all groups' aggregates always needs this "aggregate inside a subquery, compared in HAVING (or an outer WHERE over a derived table)" pattern.
🧠
Mental model: if the comparison value needs a raw column, filter in WHERE. If the comparison value needs an aggregate — of the current group or of some other computed set — filter in HAVING, and reach for a subquery the moment that aggregate has to be computed from a different grouping than the one you're currently producing.
Level 1 · Beginner

Multi-column / tuple subqueries

What it is: instead of comparing one column against a single-column subquery, you compare a row of columns — written as a parenthesized tuple — against a subquery that returns matching multiple columns per row.

Syntax

WHERE (dept_id, salary) IN (
    SELECT dept_id, MAX(salary)
    FROM employees
    GROUP BY dept_id
)

Read this as: "keep a row only if its (dept_id, salary) pair exactly matches one of the (dept_id, MAX(salary)) pairs the subquery produces" — i.e. this employee holds the top salary within their own department.

Full example — highest salary per department

SELECT e.emp_name, e.dept_id, e.salary
FROM employees e
WHERE (e.dept_id, e.salary) IN (
    SELECT dept_id, MAX(salary)
    FROM employees
    GROUP BY dept_id
);
Why not just correlated MAX?The single-column correlated version (WHERE salary = (SELECT MAX(salary) FROM employees e2 WHERE e2.dept_id = e.dept_id)) gives the same result here and reads more clearly to most engineers — the tuple form mainly earns its keep when you need to match on more than two columns at once, or want a single non-correlated subquery instead of a per-row correlated one.

Vendor support differences

  • PostgreSQL, MySQL 8+: full row/tuple comparison support, as shown above.
  • SQL Server: does not support row-value tuple comparisons in IN — you must rewrite using a join or a correlated subquery instead.
  • Snowflake, BigQuery: support tuple IN comparisons similarly to Postgres.

Alternative using a join / window function (portable everywhere)

SELECT emp_name, dept_id, salary
FROM (
    SELECT emp_name, dept_id, salary,
           RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk
    FROM employees
) ranked
WHERE rnk = 1;
Interview tipIf you're not 100% sure the tuple-IN syntax is supported on the database you're being asked about, default to the window-function rewrite above — it's portable across every mainstream engine and handles ties (two employees sharing the max salary) more transparently than an exact tuple match does.
Level 2 · Intermediate

Correlated subquery

What it is: a subquery that references a column from the outer query. Because the inner query's result depends on which outer row is currently being processed, it conceptually has to be re-evaluated once per outer row.

AnalogyA non-correlated subquery is like computing "the national average height" once and comparing every person to it. A correlated subquery is like asking, for every person, "what is the average height of just people in your own city?" — the answer depends on which person you're currently looking at, so you can't precompute one single number up front.

Easy example

Find employees who earn more than the average salary of their own department (not the company-wide average).

SELECT e.emp_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   -- <- this line correlates the subquery to the outer row
);

Line by line: for every outer row e, the inner query recomputes the average salary using only rows from e's own department (e2.dept_id = e.dept_id). Sneha Kapoor (Engineering, 98000) is compared against Engineering's average (~143,250), not the whole company's.

🔁
Mental model: mentally read a correlated subquery as a nested loop: "for each row in the outer table, run the entire inner query again, using this row's values wherever the inner query references the outer alias."
Common mistakeForgetting the correlation condition entirely (writing SELECT AVG(salary) FROM employees instead of filtering by dept_id = e.dept_id) silently turns your "compare to department average" query back into "compare to company average" — a very easy typo to make and very easy for an interviewer to plant.
Level 2 · Intermediate

NOT IN & the NULL trap

This is one of the most important gotchas in all of SQL, and it comes up constantly in interviews.

The problem, demonstrated

Goal: find customers who have never placed an order.

-- Looks reasonable...
SELECT customer_name
FROM customers
WHERE customer_id NOT IN (
    SELECT customer_id FROM orders
);
-- With OUR data (no NULLs in orders.customer_id), this correctly returns Laura Fischer.

Now watch what happens if just one row in the subquery has a NULL customer_id — which is extremely plausible in real data (an order placed as a guest checkout, with no customer linked yet):

INSERT INTO orders VALUES (5006, NULL, '2024-02-10', 300.00);  -- a guest checkout order

SELECT customer_name
FROM customers
WHERE customer_id NOT IN (
    SELECT customer_id FROM orders
);
-- Now returns ZERO ROWS. Not an error -- just silently, completely empty.

Why this happens

NOT IN (list) is logically equivalent to column != v1 AND column != v2 AND column != v3 ... for every value in the list. If any one value in that list is NULL, that one comparison becomes column != NULL, which evaluates to UNKNOWN — not TRUE, not FALSE. And TRUE AND TRUE AND UNKNOWN evaluates to UNKNOWN overall, which is treated as "not true", so the row gets filtered out. This happens for every row being tested, since every row's chain of ANDs includes that same poisoned NULL comparison — so the whole result set silently collapses to empty.

This is a favorite interview trapInterviewers love asking "what's wrong with this NOT IN query" or quietly inserting a NULL into sample data before asking you to write a NOT IN filter. The safe, professional default is always use NOT EXISTS instead of NOT IN whenever the subquery's column could ever contain a NULL — which, in practice, means almost always, unless the column has a NOT NULL constraint you've personally verified.

The safe fix

SELECT c.customer_name
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
-- Correctly returns Laura Fischer, regardless of any NULLs in orders.customer_id

NOT EXISTS never compares values directly against NULL the way NOT IN does — it just asks "does at least one matching row exist," and a NULL row in the subquery simply never matches anything, which is exactly the harmless behavior you want.

Alternative fix if you must use NOT INExplicitly filter out NULLs from the subquery: WHERE customer_id NOT IN (SELECT customer_id FROM orders WHERE customer_id IS NOT NULL). This works, but it's an easy line to forget — NOT EXISTS is simply safer by default.
Level 2 · Intermediate

ANY & ALL

What they are: operators that compare a value against every value returned by a subquery, using a comparison operator (=, >, <, etc.) combined with ANY or ALL.

  • > ANY (subquery) — true if the value is greater than at least one value returned (equivalent to > MIN(...)).
  • > ALL (subquery) — true if the value is greater than every value returned (equivalent to > MAX(...)).
Analogy"Taller than ANY of the basketball team" means taller than at least the shortest player. "Taller than ALL of the basketball team" means taller than even the tallest player.

Example — employees earning more than ALL Marketing employees

SELECT emp_name, salary
FROM employees
WHERE salary > ALL (
    SELECT salary FROM employees WHERE dept_id = 3   -- Marketing
)
AND dept_id != 3;   -- exclude Marketing itself from the comparison

This is functionally identical to comparing against MAX(salary) FROM employees WHERE dept_id = 3, but written with ALL. Most engineers actually prefer the MAX()/MIN() scalar subquery form shown in the Level 1 section — it's more widely understood at a glance than ANY/ALL, which some engineers rarely encounter.

Interview tipKnow that = ANY (subquery) is exactly equivalent to IN (subquery), and != ALL (subquery) is exactly equivalent to NOT IN (subquery) — including inheriting the exact same NULL trap discussed above. Interviewers sometimes ask you to name this equivalence directly.
Level 2 · Intermediate

Empty-set & NULL behavior

Every subquery operator has its own specific, memorizable behavior when the subquery returns zero rows, or when NULLs are mixed into the values it returns. This table is one of the highest-density interview-trap references in all of SQL — memorize it.

Behavior when the subquery returns an EMPTY set
ConstructResult over empty setWhy
= (scalar subquery)NULLA scalar subquery with zero rows returns NULL, not an error and not zero.
IN (empty set)FALSENothing to match against — membership always fails.
NOT IN (empty set)TRUE"Not a member of nothing" is trivially true (assuming no NULL poisoning — see below).
EXISTS (empty set)FALSENo rows means nothing exists.
NOT EXISTS (empty set)TRUENothing exists to contradict "not exists."
x > ALL (empty set)TRUEVacuous truth: "greater than every value in an empty set" has no counterexample, so it's true by definition.
x > ANY (empty set)FALSEThere is no value to be greater than "at least one of" — ANY needs at least one match to succeed.
Classic interview trickThis is exactly the trap in FAANG question F2 later in this module ("every employee earns above 90000") — an empty department vacuously satisfies ALL/NOT EXISTS-style conditions. Always ask out loud whether the business rule intends to include groups with zero rows before picking your query shape.

ANY / ALL with NULL values mixed in

NULLs inside the subquery's result list behave differently for ANY vs ALL, and it mirrors the IN/NOT IN NULL trap covered earlier:

  • x > ANY (1, 2, NULL) — if x is greater than 1 or 2, the whole expression is TRUE (one real match is enough; the NULL is simply ignored once a TRUE is already found).
  • x > ANY (NULL) alone (only a NULL, no real values) — UNKNOWN, which behaves like FALSE in a WHERE clause.
  • x > ALL (1, 2, NULL) — even if x beats 1 and 2, the comparison against NULL is UNKNOWN, and ALL requires every comparison to be TRUE — so the overall result becomes UNKNOWN (filtered out), never TRUE. This is silent and easy to miss.
Why != ALL is exactly as dangerous as NOT INSince != ALL (subquery) is literally equivalent to NOT IN (subquery), a single NULL anywhere in that subquery's results poisons the entire ALL comparison the same way it poisons NOT IN — silently returning zero rows instead of erroring.

Safer rewrites

-- Instead of:  salary != ALL (SELECT salary FROM other_table)
-- Prefer:
SELECT * FROM employees e
WHERE NOT EXISTS (
    SELECT 1 FROM other_table o WHERE o.salary = e.salary
);

-- Instead of:  salary > ALL (SELECT salary FROM other_table)
-- Prefer:
SELECT * FROM employees e
WHERE e.salary > (SELECT MAX(salary) FROM other_table);
-- MAX() over an empty set is NULL, and "salary > NULL" is UNKNOWN -- decide explicitly
-- whether that should mean TRUE (nothing to beat) or FALSE for your business rule,
-- rather than relying on ALL's vacuous-truth default.
Rule of thumbNOT EXISTS and MIN/MAX-based scalar comparisons never fall into the ANY/ALL/NULL traps above, because they never perform a direct row-by-row equality/inequality chain against a list that might contain NULL. Default to them whenever the underlying column is nullable.
Level 2 · Intermediate

ORDER BY & LIMIT inside subqueries

An ORDER BY placed inside a subquery is not automatically meaningless — it depends entirely on whether the subquery also has a row-limiting clause (LIMIT, TOP, FETCH FIRST).

When ORDER BY inside a subquery is ignored

SELECT * FROM employees
WHERE dept_id IN (
    SELECT dept_id FROM departments ORDER BY dept_name   -- <- pointless here
);

Without a LIMIT, the subquery still returns the exact same set of rows regardless of what order they're produced in — IN/EXISTS/set operators don't care about row order at all. Most optimizers will simply discard a useless ORDER BY here (some will even warn or refuse it, since a subquery feeding into IN conceptually has no ordering).

When it matters — paired with LIMIT/TOP/FETCH FIRST

The moment you add a row-limiting clause, ORDER BY becomes essential — it decides which row(s) survive the limit. This is the classic "latest row per group" / scalar-subquery pattern:

-- Postgres / MySQL: latest order date per customer, via a correlated scalar subquery
SELECT c.customer_name,
       (SELECT o.order_date
        FROM orders o
        WHERE o.customer_id = c.customer_id
        ORDER BY o.order_date DESC
        LIMIT 1) AS latest_order_date
FROM customers c;

Here ORDER BY ... LIMIT 1 is doing real work: it picks exactly one row (the most recent) out of potentially many matching rows, and the scalar subquery is only legal at all because it's guaranteed to return at most one row.

Deterministic tie-breakersIf two orders share the exact same order_date, ORDER BY order_date DESC LIMIT 1 picks one of them arbitrarily — which one is not guaranteed to be stable across runs. Always add a tie-breaker column with a unique value (ORDER BY order_date DESC, order_id DESC LIMIT 1) whenever "the latest row" needs to be deterministic, e.g. for anything feeding a report or a downstream join.
Production alternativeThe same "latest row per group" problem is usually solved more efficiently at scale with a window function (ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC), filtered to 1 — see Module 6) or a LATERAL/APPLY join (covered later in this module) rather than a correlated scalar subquery re-run per outer row.
Level 3 · Advanced

EXISTS & NOT EXISTS

What it is: a boolean test — TRUE if the subquery returns at least one row, FALSE if it returns none. EXISTS never cares what columns the subquery selects (writing SELECT 1 or SELECT * inside makes no difference) — only whether a row exists.

This is the "SEMI JOIN" pattern from Module 1, formalized.

Easy example

SELECT c.customer_name
FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
-- Rahul, Grace, Omar (Laura excluded -- she has no matching order row)
🧠
Mental model: EXISTS is a correlated subquery almost by definition — it's checking, per outer row, "is there a matching row for this specific outer value." Good query engines short-circuit the inner scan the moment they find one matching row — they don't need to find every match, just confirm at least one exists, which is often faster than a full JOIN + DISTINCT for pure existence checks.
Production use: validations & data qualityEXISTS/NOT EXISTS are the standard tools for data quality checks: "find orders with no matching customer" (referential integrity check), "find products never sold" (dead inventory check), "find employees not in any project" (utilization check). They answer yes/no questions without the row-duplication risk of a JOIN.
Level 3 · Advanced

Execution flow internals: how correlated subqueries really run

Logically, a correlated subquery behaves like a nested loop: for every row the outer query considers, the database substitutes that row's values into the inner query and re-evaluates it from scratch.

SELECT e.emp_name
FROM employees e
WHERE EXISTS (
    SELECT 1 FROM employees m WHERE m.manager_id = e.emp_id
);
-- "find employees who manage at least one other employee"

Conceptual execution, row by row:

  1. Take outer row: Arjun Mehta (emp_id=101). Run inner query with manager_id = 101 → finds Priya & Rohit → EXISTS is TRUE → keep Arjun.
  2. Take outer row: Priya Nair (emp_id=102). Run inner query with manager_id = 102 → finds Sneha → EXISTS is TRUE → keep Priya.
  3. Take outer row: Sneha Kapoor (emp_id=104). Run inner query with manager_id = 104 → finds nobody → EXISTS is FALSE → drop Sneha.
  4. ...continue for all 16 rows.
Performance issueNaively, that's O(n) outer rows × O(n) inner scan each = O(n²) in the worst case, with no index. In practice, real query optimizers are smarter than a literal nested loop — many rewrite correlated subqueries into semi-joins or hash-based plans internally when they can prove it's safe to do so. But you should never assume the optimizer will save you — always check EXPLAIN on a correlated subquery running against a large table, and add an index on the correlation column (manager_id here) if it's missing.
Rule of thumbA correlated subquery inside a WHERE EXISTS is usually fine, even on large tables, if the correlated column is indexed — the "re-run per row" becomes a cheap indexed lookup rather than a full table scan. Without that index, correlated subqueries are one of the most common causes of "this query used to be fast and now it's slow" as tables grow.
Level 3 · Advanced

Rewriting subqueries as joins

Many subqueries have an equivalent JOIN form. Knowing both, and knowing when to prefer which, is a strong interview signal.

IN subquery → INNER JOIN + DISTINCT

-- Subquery form
SELECT customer_name FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders);

-- Equivalent JOIN form
SELECT DISTINCT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;

Both give the same result here. The JOIN form needs DISTINCT because a customer with multiple orders would otherwise be duplicated (Module 1's cardinality issue) — the subquery form never had that problem in the first place, since IN only checks membership.

EXISTS → semi-join (same idea)

-- EXISTS form (preferred for pure existence checks)
SELECT c.customer_name
FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);

-- JOIN + DISTINCT form (works, but needs the DISTINCT safety net)
SELECT DISTINCT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
When to prefer which
  • Prefer EXISTS/IN when you only need a yes/no filter and don't need any columns from the "other" table — it avoids duplication entirely and is often clearer about intent.
  • Prefer a JOIN when you actually need to pull columns from the other table into your result — a subquery can't return those columns to the outer SELECT.
  • Prefer a correlated subquery for per-row comparisons against a per-group aggregate (like "above my own department's average") when a window function (Module 6) isn't available or feels heavier than needed for a one-off query.
Level 3 · Advanced

Subqueries in SELECT, FROM, and WHERE

Subquery in FROM (a "derived table" / inline view)

SELECT dept_id, avg_salary
FROM (
    SELECT dept_id, AVG(salary) AS avg_salary
    FROM employees
    GROUP BY dept_id
) dept_avgs
WHERE avg_salary > 100000;

The inner query is treated as if it were a real table — you must give it an alias (dept_avgs here), and every database requires this alias even though you never reference it by name in a simple case like this.

Subquery in SELECT (scalar, per-row)

SELECT
    d.dept_name,
    (SELECT COUNT(*) FROM employees e WHERE e.dept_id = d.dept_id) AS headcount
FROM departments d;

This is a correlated scalar subquery run once per department row — functionally similar to a LEFT JOIN + GROUP BY, but sometimes easier to read for a single extra computed column. At scale, prefer the JOIN + GROUP BY form (Module 1) since it's usually a single pass instead of one subquery execution per outer row.

Common mistakeUsing a subquery in FROM without an alias is a syntax error in most databases (MySQL, Postgres both require it) — always alias derived tables, even if you never reference the alias again.
Level 3 · Advanced

Subqueries with UPDATE, DELETE, and INSERT

Everything so far has been read-only SELECT. Subqueries are just as common — and much higher-stakes — inside the statements that actually change data.

UPDATE with a subquery — refresh a denormalized value

-- Keep a denormalized "current_headcount" column on departments in sync
UPDATE departments d
SET current_headcount = (
    SELECT COUNT(*) FROM employees e WHERE e.dept_id = d.dept_id
);

The scalar subquery is re-evaluated once per row of departments being updated — exactly the correlated-subquery execution model from earlier, just inside a SET clause instead of a SELECT.

DELETE with a subquery — remove orphan rows

DELETE FROM orders o
WHERE NOT EXISTS (
    SELECT 1 FROM customers c
    WHERE c.customer_id = o.customer_id
);
-- Deletes order 5005 (customer_id 999, which doesn't exist in customers)

INSERT INTO ... SELECT — load filtered/aggregated rows into a reporting table

INSERT INTO dept_salary_report (dept_id, avg_salary, headcount)
SELECT dept_id, AVG(salary), COUNT(*)
FROM employees
GROUP BY dept_id
HAVING COUNT(*) > 0;

No parentheses needed around the SELECT here — INSERT INTO ... SELECT ... is its own first-class statement shape, not a subquery nested inside another clause, but it's built from the exact same querying skills.

Safety rule — always run the equivalent SELECT firstBefore running any DELETE or UPDATE that relies on a subquery to decide which rows are affected, run the identical filter as a plain SELECT first and eyeball the row count and the actual rows. A correlated-alias mistake (WHERE c.customer_id = c.customer_id instead of o.customer_id, for example) silently changes every row instead of the intended subset — and unlike a SELECT, that mistake is not easily undone.
Production habitWrap DML-with-subquery statements in an explicit transaction (BEGIN ... COMMIT) so a mistake can be rolled back, and prefer running the pre-check SELECT and the actual DML inside the same transaction where the tooling allows it, so the row set can't shift between the check and the change.
Level 3 · Advanced

LATERAL / APPLY: correlated subqueries in FROM

A plain subquery in FROM (a derived table) is not allowed to reference columns from other tables earlier in the same FROM clause — it has to be fully self-contained. LATERAL (PostgreSQL) and CROSS APPLY/OUTER APPLY (SQL Server) remove that restriction: the subquery can reference columns from preceding tables, row by row.

Vendor syntax
DatabaseKeywordNULL-preserving version
PostgreSQLLATERALLEFT JOIN LATERAL ... ON true
SQL ServerCROSS APPLYOUTER APPLY

Use case — latest order per customer (Postgres LATERAL)

SELECT c.customer_name, latest.order_id, latest.order_date
FROM customers c
LEFT JOIN LATERAL (
    SELECT o.order_id, o.order_date
    FROM orders o
    WHERE o.customer_id = c.customer_id   -- referencing the outer table's column: only legal because of LATERAL
    ORDER BY o.order_date DESC
    LIMIT 1
) latest ON true;
-- LEFT JOIN LATERAL keeps Laura Fischer with NULLs, since she has no orders at all

Use case — top N rows per parent (SQL Server CROSS APPLY)

SELECT c.customer_name, top_orders.order_id, top_orders.total_amount
FROM customers c
CROSS APPLY (
    SELECT TOP 2 o.order_id, o.total_amount
    FROM orders o
    WHERE o.customer_id = c.customer_id
    ORDER BY o.total_amount DESC
) top_orders;
-- Each customer contributes their own top 2 orders by amount -- impossible to express with a plain subquery in FROM

CROSS APPLY vs OUTER APPLY

  • CROSS APPLY — behaves like an INNER JOIN: if the lateral subquery returns zero rows for a given outer row, that outer row is dropped entirely.
  • OUTER APPLY — behaves like a LEFT JOIN: if the lateral subquery returns zero rows, the outer row is kept with NULLs for the lateral columns.
🔁
Mental model: a normal subquery in FROM is computed once, in isolation, before the outer query even starts joining. A LATERAL/APPLY subquery is computed once per outer row, like a correlated subquery — except, unlike a correlated subquery in WHERE or SELECT, it can return multiple columns and multiple rows back into the join.
Other use case worth knowingLATERAL/APPLY is also the standard way to "explode" a JSON array or a comma-separated list stored per row into one output row per element, joining a set-returning function (like Postgres's jsonb_array_elements) laterally against each parent row.
Level 3 · Advanced

Optimizer decorrelation & subquery materialization

Everything said so far describes subqueries logically — as if the database really does re-run a correlated subquery once per outer row, in a loop. In practice, real optimizers frequently rewrite the query into something that runs very differently, while guaranteeing the same result.

What decorrelation means

Decorrelation is the optimizer proving that a correlated subquery can be rewritten as an equivalent join or semi-join, so it can be executed as a single set-based pass instead of a per-row loop.

-- Written as a correlated subquery...
SELECT e.emp_name
FROM employees e
WHERE EXISTS (
    SELECT 1 FROM employees m WHERE m.manager_id = e.emp_id
);

-- ...but the optimizer is free to internally execute it more like a semi-join:
SELECT DISTINCT e.emp_name
FROM employees e
SEMI JOIN employees m ON m.manager_id = e.emp_id;   -- conceptual, not literal syntax in most engines

When the optimizer can decorrelate

  • The correlation is a simple equality (inner.col = outer.col), not a complex expression.
  • The subquery doesn't depend on non-deterministic functions or side effects.
  • The overall semantics (including how NULLs and duplicates behave) can be proven identical after rewriting.

When it cannot decorrelate

  • The correlated subquery uses LIMIT/TOP/ORDER BY to pick a specific row per outer row (the "latest row per group" pattern) — this genuinely needs a per-row computation, which is exactly why LATERAL/APPLY exists as an explicit, guaranteed-correlated construct.
  • The subquery references outer columns inside aggregate functions or nested subqueries in ways the optimizer can't safely flatten.
  • Some older optimizer versions simply don't implement decorrelation for a given shape, even when it would theoretically be safe.
The interview answer to memorize"Logically, a correlated subquery runs row-by-row. Physically, the optimizer may rewrite it into a join or semi-join if it can prove the rewrite is equivalent — but I never assume that happened without checking EXPLAIN."

Materialization vs inlining a derived table

For a non-correlated subquery in FROM (a derived table), the optimizer has a similar choice: materialize it (run it once, fully, into a temporary result, then join against that) or inline/flatten it (merge its logic directly into the outer query plan, sometimes pushing outer filters down into it).

  • Predicate pushdown: a filter from the outer query (WHERE avg_salary > 100000) gets pushed down inside the derived table's own query plan, so the database filters early instead of computing the full derived table and filtering afterward.
  • Optimization barrier: some constructs (an aggregate, DISTINCT, LIMIT, a window function inside the derived table) prevent pushdown — the database must materialize that subquery fully before applying outer filters, since pushing the filter in earlier would change the result.
Rough behavior across engines (always confirm with EXPLAIN — this changes across versions)
EngineTendency
PostgreSQLAggressively inlines simple derived tables; materializes when a barrier (aggregate/window/LIMIT) is present, or when explicitly hinted.
MySQLHistorically materialized derived tables by default; newer versions (8+) support derived table merging/pushdown for simple cases.
SQL ServerCost-based; frequently flattens simple subqueries into the main plan, keeps complex ones as separate operators.
BigQuery / SnowflakeMPP engines that typically materialize intermediate stages between distributed execution steps regardless of subquery vs CTE form.
How to inspect itRun EXPLAIN (or EXPLAIN ANALYZE in Postgres) and look for whether the derived table appears as its own separate node/materialization step in the plan, or whether its conditions have been merged into the surrounding scan/join nodes. Never guess — the same-looking query can execute completely differently across engines and versions.
Level 3 · Advanced

Subquery vs join vs CTE vs window function vs temp table

By this point in the module you know four or five different tools that can often solve the exact same problem. Knowing which one to reach for — and being able to say why out loud — is a stronger interview signal than knowing any single syntax.

When each tool is the better default
ToolBest forWatch out for
Subquery (scalar/IN/EXISTS)One-off existence checks or a single computed comparison value, used onceGets hard to read once nested 2-3 levels deep
JOINYou need actual columns from the other table in your resultRow duplication/fan-out if the relationship isn't 1:1 — needs DISTINCT or careful grouping
CTE (WITH ...)The same derived result is referenced more than once, or deep nesting needs to be named and flattened for readabilitySome engines materialize every CTE reference (recompute each time) unless explicitly hinted otherwise — check EXPLAIN
Window functionPer-row ranking, running totals, or "top N per group" without collapsing rows via GROUP BYNot supported in very old engine versions; can be less obvious to a reader unfamiliar with them
Temp tableAn intermediate result reused across multiple separate statements, or large enough that materializing once with its own indexes beats recomputingExtra DDL/cleanup overhead; overkill for a single query

Production maintainability & readability tradeoffs

  • A deeply nested subquery (subquery inside a subquery inside a subquery) is usually the hardest of all five forms for a future reader — including future you — to follow. A CTE that names each intermediate step almost always reads better for anything beyond one level of nesting.
  • Prefer the tool that most directly states your intent: EXISTS says "I only care whether a match exists"; a JOIN says "I need columns from both sides"; a window function says "I'm comparing each row to its peers without collapsing rows."
  • None of these are mutually exclusive in real production SQL — a well-written query commonly combines a CTE, a window function, and a small correlated subquery in the same statement, each doing the piece it's best suited for.
Interview framing that lands well"I'd start with EXISTS since I only need a yes/no filter here — but if the interviewer wanted the matching columns too, I'd switch to a JOIN, and if this logic got reused three times in the same query, I'd pull it into a CTE first." Naming the tradeoff, not just producing one correct query, is what separates a strong answer.
Level 4 · Hard Interview

How interviewers trick people on subqueries

  1. The NOT IN + NULL trap — covered in depth above. This is probably the single most common SQL interview gotcha across all companies.
  2. The forgotten correlation trap — they ask for "above department average" and quietly check whether you correlate the inner query by dept_id, or accidentally compute the company-wide average instead.
  3. The "subquery returns multiple rows" trap — they ask you to use a scalar subquery (= (SELECT ...)) against data where the subquery could return more than one row, to see if you catch the runtime error before running it.
  4. The EXISTS vs IN vs JOIN performance question — "which is fastest?" The honest answer is "it depends on the optimizer, data size, and indexes — but EXISTS is generally safest for a pure existence check, since it can short-circuit and never risks duplicate rows." Interviewers want to see you avoid a dogmatic one-line answer.
  5. The double-negative trap — "find employees NOT in a department that has NO employees earning over 100000" nests two negations; many candidates fumble translating this into nested NOT EXISTS clauses correctly.
Level 4 · Hard Interview

3 Hard Interview Questions

H1.Find the department(s) with the highest average salary. hard
Expected output

dept_name, avg_salary — for whichever department(s) tie for the single highest average (handle ties correctly, don't just LIMIT 1).

Hint

Compute all department averages in a derived table or CTE, then compare each against the MAX of that same set.

Full solution
SELECT dept_name, avg_salary
FROM (
    SELECT d.dept_name, AVG(e.salary) AS avg_salary
    FROM departments d
    JOIN employees e ON d.dept_id = e.dept_id
    GROUP BY d.dept_name
) dept_avgs
WHERE avg_salary = (
    SELECT MAX(avg_salary)
    FROM (
        SELECT AVG(salary) AS avg_salary
        FROM employees
        GROUP BY dept_id
    ) all_avgs
);
-- Result: Engineering (~143,250)
Common mistakes
  • Using ORDER BY avg_salary DESC LIMIT 1 — this silently drops a genuine tie if two departments happen to share the exact highest average. Always prefer the = (SELECT MAX(...)) pattern when ties matter.
  • Forgetting to exclude Legal (0 employees, NULL average) — it's automatically excluded here since we used an INNER JOIN grouping, but worth stating out loud in an interview.
Interview follow-ups
  • "Now do this with a window function instead." (foreshadowing: RANK() OVER (ORDER BY avg_salary DESC), Module 6)
H2.Find customers whose total order amount is above the average total order amount across all customers. hard
Hint

This needs two levels of aggregation: per-customer totals first, then the average of those totals, then a filter. A single-level GROUP BY + HAVING can't reach "average of the per-group sums" directly — you need a derived table.

Full solution
SELECT customer_id, total_spent
FROM (
    SELECT customer_id, SUM(total_amount) AS total_spent
    FROM orders
    WHERE customer_id IS NOT NULL
    GROUP BY customer_id
) customer_totals
WHERE total_spent > (
    SELECT AVG(total_spent)
    FROM (
        SELECT customer_id, SUM(total_amount) AS total_spent
        FROM orders
        WHERE customer_id IS NOT NULL
        GROUP BY customer_id
    ) t
);
Common mistakes
  • Trying to write HAVING SUM(total_amount) > AVG(SUM(total_amount)) in one single GROUP BY — this is invalid in standard SQL; you cannot nest an aggregate directly inside another aggregate in the same grouping level. The derived-table (or CTE) two-step is required.
  • Forgetting to exclude the orphaned/NULL customer_id order (5005/5006 style rows) from both the inner sums and the outer average — an ungrounded NULL customer_id would either error out or silently create a bogus grouping bucket depending on the engine.
H3.Find employees who are managers, but ALL of their direct reports earn less than 90000. hard
Hint

This is a double condition: (1) must actually have at least one report (EXISTS), and (2) must have zero reports earning ≥ 90000 (NOT EXISTS with a flipped condition) — or use ALL directly.

Full solution — using NOT EXISTS (safe, recommended)
SELECT m.emp_name
FROM employees m
WHERE EXISTS (
    SELECT 1 FROM employees e WHERE e.manager_id = m.emp_id
)
AND NOT EXISTS (
    SELECT 1 FROM employees e WHERE e.manager_id = m.emp_id AND e.salary >= 90000
);
Alternative solution — using ALL
SELECT m.emp_name
FROM employees m
WHERE EXISTS (SELECT 1 FROM employees e WHERE e.manager_id = m.emp_id)
AND 90000 > ALL (
    SELECT e.salary FROM employees e WHERE e.manager_id = m.emp_id
);
Common mistakes
  • Skipping the first EXISTS check — without it, a manager with zero reports would trivially satisfy "NOT EXISTS a report earning ≥ 90000" (vacuously true, since there are no reports to violate the rule), incorrectly including individual contributors with no reports at all.
Interview follow-ups
  • "What does 'ALL of an empty set satisfies any condition' mean, and why is it dangerous here?" — a classic logic gotcha: ALL / vacuous truth over an empty set is TRUE by definition, which is exactly why the EXISTS guard is required.
Level 4 · Hard Interview

Subquery debugging & error cases

Recognizing these error shapes on sight — before running anything — is what separates someone who's memorized syntax from someone who understands what a subquery actually does under the hood.

Scalar subquery returns more than one row

SELECT emp_name FROM employees
WHERE salary = (SELECT salary FROM employees WHERE dept_id = 1);
-- ERROR: more than one row returned by a subquery used as an expression
-- Engineering (dept_id 1) has multiple employees -- the subquery isn't actually scalar here

Fix: aggregate it down to one value (MAX/MIN/AVG), or switch to IN if you genuinely want to compare against a list rather than a single value.

Subquery returns multiple columns where one is expected

SELECT emp_name FROM employees
WHERE dept_id IN (SELECT dept_id, dept_name FROM departments);
-- ERROR: subquery has too many columns
-- IN expects the subquery to return exactly one column to compare against

Fix: select only the one column you're comparing, or use the tuple-comparison form (WHERE (col1, col2) IN (SELECT col1, col2 FROM ...)) if you genuinely need a multi-column match.

Correlated subquery references the wrong alias

SELECT e.emp_name
FROM employees e
WHERE e.salary > (
    SELECT AVG(salary) FROM employees e2 WHERE e2.dept_id = e2.dept_id   -- BUG: e2.dept_id = e2.dept_id, always true
);
-- Doesn't error -- silently computes the company-wide average instead of the department average,
-- because the "correlation" condition accidentally compares e2 to itself instead of to the outer e
This is worse than a syntax errorA wrong-alias bug like this doesn't crash — it runs, returns a plausible-looking result, and is wrong. Always double check that a correlation condition mentions both the outer alias and the inner alias on opposite sides of the comparison.

Derived table missing an alias

SELECT * FROM (SELECT dept_id, AVG(salary) FROM employees GROUP BY dept_id);
-- ERROR: subquery in FROM must have an alias
-- Postgres and MySQL both require this even though the alias is never referenced again

Fix: always name it — ... GROUP BY dept_id) dept_avgs.

ORDER BY not allowed or silently ignored in some subquery contexts

  • An ORDER BY inside a subquery feeding IN/EXISTS/set operators is either ignored or, in some engines (SQL Server historically), rejected outright with an error unless paired with TOP/OFFSET-FETCH.
  • An ORDER BY inside a scalar subquery paired with LIMIT 1 is not just allowed but required for the result to be deterministic — the difference is entirely about whether a row-limiting clause is present (see the ORDER BY / LIMIT section above).
Debugging checklistWhen a subquery errors or looks wrong: (1) run it standalone first, without the outer query, to see exactly what it returns; (2) count how many rows/columns it produced; (3) for a correlated subquery, manually substitute one real outer row's values in and re-run it in isolation; (4) check every alias appears on both sides of a correlation condition, never compared to itself.
Level 5 · FAANG

2 FAANG-Level Questions

F1.Second-highest distinct salary overall, using only a subquery (no LIMIT/OFFSET, no window functions). faang
Problem statement

This is one of the most repeated interview questions in existence — precisely because there are several ways to solve it, and the "wrong" ways all fail on ties or missing values in specific, revealing ways.

Hint

"Second highest" = the max salary strictly less than the overall max.

Full solution
SELECT MAX(salary) AS second_highest_salary
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- Overall max is Arjun at 185000 -> second highest is 160000 (Karan Verma)
Alternative solution — correlated subquery counting how many distinct salaries are above each row
SELECT DISTINCT e1.salary
FROM employees e1
WHERE 1 = (
    SELECT COUNT(DISTINCT e2.salary)
    FROM employees e2
    WHERE e2.salary > e1.salary
);
-- "exactly 1 distinct salary is greater than mine" = I am the second highest
Optimized solution

For "Nth highest" generalized, a window function (DENSE_RANK() OVER (ORDER BY salary DESC), filtered to rank = N) is the cleanest and most maintainable production answer — see Module 6. The subquery forms above are the classic interview answer specifically because window functions weren't always available/allowed as a constraint in the question.

Time complexity

First solution: O(n) for each MAX scan, effectively O(n) overall with a single pass (or O(log n) with an index on salary). Second solution: O(n²) without an index, since it's a correlated subquery per row.

Common mistakes
  • Using ORDER BY salary DESC LIMIT 1 OFFSET 1 — works, but silently gives the wrong answer if there's a tie for first place (in our data, if the two highest earners tied, OFFSET 1 would return that same tied value, not the "true" second distinct value). The question explicitly says "distinct" salary for this reason.
  • Forgetting DISTINCT when the underlying data has salary ties elsewhere in the table (it doesn't affect this specific MAX-based solution, but does affect naive OFFSET-based ones).
Interview follow-ups
  • "Generalize this to the Nth highest salary." (Motivates window functions directly — this single question is the classic bridge from subqueries to Module 6.)
F2.Find all departments where every single employee earns above 90000 (a "fully senior" department). faang
Problem statement

Notice the trap: Legal has zero employees. Does "every employee earns above 90000" vacuously include Legal? This is a genuine judgment call interviewers use to test whether you think about edge cases, not just write syntax.

Hint

"Every employee is above X" = "NOT EXISTS an employee at or below X" — but decide deliberately whether to require at least one employee to exist first.

Full solution — excluding empty departments (the safer business interpretation)
SELECT d.dept_name
FROM departments d
WHERE EXISTS (
    SELECT 1 FROM employees e WHERE e.dept_id = d.dept_id
)
AND NOT EXISTS (
    SELECT 1 FROM employees e WHERE e.dept_id = d.dept_id AND e.salary <= 90000
);
-- Result: none of our 5 non-empty departments qualify (each has at least one employee at or below 90000)
Alternative solution — including empty departments (the "vacuous truth" interpretation)
SELECT d.dept_name
FROM departments d
WHERE NOT EXISTS (
    SELECT 1 FROM employees e WHERE e.dept_id = d.dept_id AND e.salary <= 90000
);
-- Result: Legal included, since it has no violating employees (vacuously true)
Common mistakes
  • Picking one interpretation without stating the ambiguity out loud. A strong interview answer explicitly says: "Do you want departments with zero employees to count as trivially satisfying this? I'll assume not, but here's the one-line change if you want the other interpretation" — and then shows both queries, exactly as above.
Interview follow-ups
  • "Now require it in SQL that treats vacuous truth as invalid by business rule — how would you encode 'must have at least 3 employees to qualify'?" (Add a HAVING COUNT(*) >= 3 in a GROUP BY version instead.)
Level 5 · FAANG

1 Production Scenario

Referential integrity check before a warehouse load

Before loading a new batch of orders into a data warehouse fact table, a standard pre-load data quality gate checks for orphaned foreign keys — orders referencing a customer_id that doesn't (yet) exist in the customer dimension. Loading them anyway would create the fan-out / "UNKNOWN" join gaps discussed in Module 1's production scenario.

-- Data quality gate: fail the load (or quarantine these rows) if any exist
SELECT o.order_id, o.customer_id
FROM orders o
WHERE NOT EXISTS (
    SELECT 1 FROM customers c WHERE c.customer_id = o.customer_id
);
-- Flags order 5005 (customer_id 999, doesn't exist in customers)

Production pipelines typically run this exact NOT EXISTS check as an automated assertion step (in dbt, this pattern is literally the built-in relationships test) and either: (a) halt the pipeline and alert, (b) route the offending rows to a quarantine table for manual review, or (c) load them anyway but flag them, matching the "UNKNOWN" category approach from Module 1 — the right choice depends on whether late-arriving dimension data is expected and normal, or a genuine data bug.

Why NOT EXISTS, not NOT IN, hereIf orders.customer_id can ever be NULL (guest checkouts, as seen earlier), a NOT IN version of this exact check would silently report zero problems even when real orphaned rows exist — the worst possible failure mode for a data quality gate: appearing to pass while hiding real issues.
Production Engineering

Production data-quality patterns

This section pulls together everything a data/backend engineer actually reaches for daily once subqueries move from interview whiteboard to a real pipeline.

Performance indexing for correlated subqueries

A correlated subquery re-runs once per outer row — if the correlation column isn't indexed, that's a full table scan per outer row, which is catastrophic at scale.

-- Index the column the correlated subquery filters on
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

-- For EXISTS checks with an extra filter, a composite index on both columns helps the most
CREATE INDEX idx_employees_dept_salary ON employees(dept_id, salary);
  • Index the exact column used in the correlation condition (WHERE o.customer_id = c.customer_id → index orders.customer_id).
  • Index columns used inside an EXISTS's inner filter, not just the join column, if the inner query also filters (e.g. AND e.salary > 90000 → composite index on (manager_id, salary)).
  • Without the index, an unindexed correlated subquery degrades toward O(outer_rows × inner_table_size) — the single most common cause of "this query used to be instant and now it times out" as a table grows past a few hundred thousand rows.

Semi-join & anti-join patterns

EXISTS is a semi-join: it returns each outer row at most once, purely as a yes/no gate, never duplicating it even if many inner rows match. NOT EXISTS is an anti-join: the mirror image, keeping outer rows with zero matches.

Why they're the production default over JOIN + DISTINCTA plain JOIN followed by DISTINCT gets the same final row set for a pure existence check, but it does real, wasted work: producing every matching combination first, then throwing the duplicates away. A semi/anti-join never produces the duplicates in the first place — better for both correctness clarity and, in most optimizers, performance.

Common production data-quality checks (the same shape, over and over)

-- 1. Orphaned facts (fact row with no matching dimension row)
SELECT * FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM customers c WHERE c.customer_id = o.customer_id);

-- 2. Dimensions with no facts (dead inventory / never-used entity)
SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);

-- 3. Duplicate keys (should be unique but aren't)
SELECT customer_id, COUNT(*) FROM orders
GROUP BY customer_id, order_date HAVING COUNT(*) > 1;

-- 4. Quarantine bad rows instead of failing the whole batch load
INSERT INTO orders_quarantine
SELECT * FROM staging_orders s
WHERE NOT EXISTS (SELECT 1 FROM customers c WHERE c.customer_id = s.customer_id);
This is dbt's built-in relationship testThe "orphaned fact" query above is exactly what dbt's relationships schema test runs before every model build — if you've used dbt, you've already been relying on this NOT EXISTS pattern without necessarily writing it by hand.

Handling NULL business rules (the guest-checkout pattern)

Before writing an orphan check, you first have to decide: is a NULL foreign key an actual data bug, or an expected, allowed state (a guest checkout with no linked customer yet)? Conflating the two is a common production mistake.

-- Split into two separate, deliberate checks instead of one ambiguous query:

-- (a) TRUE orphans: has a customer_id, but it doesn't exist in customers -- a real bug
SELECT * FROM orders o
WHERE o.customer_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM customers c WHERE c.customer_id = o.customer_id);

-- (b) Expected/allowed NULLs: guest checkouts -- report separately, don't fail the pipeline on these
SELECT * FROM orders o
WHERE o.customer_id IS NULL;
Why this mattersA pipeline that treats every NULL customer_id as a data-quality failure will page someone at 3am for completely legitimate guest-checkout traffic. A pipeline that ignores NULLs entirely might be hiding a genuinely broken upstream ID generator. Explicitly separating the two branches, as above, is the correct production pattern either way.

Deduplication with subqueries

-- Keep only the lowest-id row per duplicate key, delete the rest
DELETE FROM orders o
WHERE o.order_id NOT IN (
    SELECT MIN(o2.order_id)
    FROM orders o2
    GROUP BY o2.customer_id, o2.order_date
);
-- Compare with the window-function version: ROW_NUMBER() OVER (PARTITION BY customer_id, order_date
-- ORDER BY order_id) filtered to rn > 1, then delete -- usually preferred in production for large tables,
-- since it avoids the correlated MIN() re-scan per group.
Production safety for dedup deletesAlways: (1) run the equivalent SELECT and manually verify the row count matches expectations, (2) wrap the DELETE in a transaction, (3) take a backup or snapshot of the table first if it's not trivially re-loadable, (4) for very large tables, delete in small batches rather than one giant transaction that locks the table.

Subqueries in CHECK constraints — why they usually don't work

It's tempting to try to enforce cross-row business rules directly at the schema level:

-- This looks reasonable, but most databases REJECT a subquery inside CHECK:
ALTER TABLE orders ADD CONSTRAINT chk_customer_exists
CHECK (customer_id IN (SELECT customer_id FROM customers));  -- typically not allowed
Important nuance for interviewsThe SQL standard and most engines (PostgreSQL, MySQL, SQL Server) do not allow subqueries inside CHECK constraints, because a CHECK is meant to validate a single row in isolation, not depend on the state of another table that could change independently. The correct tools for this exact rule are a proper foreign key constraint (for referential integrity), a trigger (for more complex cross-row rules the engine can enforce at write time), or a pipeline-level test/assertion (dbt-style, run before or after a load) — not CHECK. Naming this limitation unprompted is a strong signal in a database-design interview.
Production Engineering

Incremental loads with NOT EXISTS

Instead of truncating and reloading an entire target table every run, an incremental load only inserts rows from staging that aren't already present in the target — the anti-join pattern applied to pipeline design.

INSERT INTO target (order_id, customer_id, order_date, total_amount)
SELECT s.order_id, s.customer_id, s.order_date, s.total_amount
FROM staging s
WHERE NOT EXISTS (
    SELECT 1 FROM target t WHERE t.order_id = s.order_id
);

This is the same anti-join shape as the orphan/data-quality checks above, just aimed at "which staging rows are genuinely new" instead of "which fact rows are broken."

Why this needs to be idempotent

A pipeline is idempotent if running it twice on the same staging batch produces the same end state as running it once — no duplicate rows from an accidental re-run. The NOT EXISTS anti-join gives you this for free: if the same staging batch is (accidentally or deliberately) reprocessed, every row it already inserted last time now already exists in target, so NOT EXISTS correctly skips them the second time.

Production checklist for incremental loads
  • Match on the true unique/primary key of the target table (order_id here) — matching on a non-unique column would let genuine duplicates slip past the anti-join.
  • Index that key on the target side — the anti-join runs this lookup once per staging row, so an unindexed target turns the load into a full scan per row.
  • Decide up front whether "already exists" should also check for changed values (an upsert/MERGE pattern) rather than just presence — plain NOT EXISTS only handles brand-new rows, not updates to existing ones.
  • Run the same referential-integrity NOT EXISTS checks from the previous section against staging before this insert, so bad rows are quarantined rather than silently loaded into target.
Practice

10 Practice Questions

Write the SQL yourself before expanding each answer.

P1.Find employees who earn the maximum salary in the whole company. easy
SELECT emp_name, salary
FROM employees
WHERE salary = (SELECT MAX(salary) FROM employees);
P2.Find departments that have at least one employee earning over 150000. easy
SELECT dept_name
FROM departments d
WHERE EXISTS (
    SELECT 1 FROM employees e WHERE e.dept_id = d.dept_id AND e.salary > 150000
);
P3.Find employees who are NOT anyone's manager. easy
SELECT emp_name
FROM employees e
WHERE NOT EXISTS (
    SELECT 1 FROM employees m WHERE m.manager_id = e.emp_id
);
P4.Find customers who have placed more than one order. medium
SELECT customer_id
FROM (
    SELECT customer_id, COUNT(*) AS order_count
    FROM orders
    WHERE customer_id IS NOT NULL
    GROUP BY customer_id
) t
WHERE order_count > 1;
-- Rahul Bansal (201) placed 2 orders
P5.Find employees earning more than their department's average, using a correlated subquery. medium
SELECT e.emp_name
FROM employees e
WHERE e.salary > (
    SELECT AVG(e2.salary) FROM employees e2 WHERE e2.dept_id = e.dept_id
);
P6.Find departments with zero employees, using NOT EXISTS. medium
SELECT dept_name
FROM departments d
WHERE NOT EXISTS (
    SELECT 1 FROM employees e WHERE e.dept_id = d.dept_id
);
-- Legal
P7.Find the employee(s) with the lowest salary in each department (one subquery per department comparison). medium
SELECT e.emp_name, e.dept_id, e.salary
FROM employees e
WHERE e.salary = (
    SELECT MIN(e2.salary) FROM employees e2 WHERE e2.dept_id = e.dept_id
);
P8.Explain, without running it, why WHERE dept_id NOT IN (SELECT dept_id FROM employees) could return zero departments even if Legal genuinely has no employees. hard

Because employees.dept_id contains NULLs (Tanvi Desai, Sameer Gupta) — the subquery's result list includes a NULL, which poisons every comparison in the implicit AND-chain that NOT IN performs, collapsing the whole result to empty. The safe rewrite is NOT EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.dept_id).

P9.Find employees whose salary is above the average salary of employees who report to the same manager as them (peer average, not department average). hard
SELECT e.emp_name, e.salary
FROM employees e
WHERE e.salary > (
    SELECT AVG(e2.salary)
    FROM employees e2
    WHERE e2.manager_id = e.manager_id
);
-- Note: employees with manager_id = NULL (Arjun, Karan, Meera, Divya, Ishita) compare against
-- the average of OTHER NULL-manager employees -- since NULL = NULL is never true in the correlation,
-- double check this edge case: most engines treat "e2.manager_id = e.manager_id" as UNKNOWN when both are NULL,
-- so top-level employees would each compare against an EMPTY set (AVG of nothing = NULL), and salary > NULL is UNKNOWN --
-- meaning all NULL-manager employees are silently excluded. Flag this out loud as a known limitation.
P10.Find orders placed by customers from a country other than India. hard
SELECT order_id, total_amount
FROM orders
WHERE customer_id IN (
    SELECT customer_id FROM customers WHERE country != 'India'
);
-- Grace Lin's and Omar Haddad's orders (5002, 5004)
-- Note: order 5005 (customer_id 999) is correctly excluded, since 999 isn't in the customers list at all