SQL Course L3 Track / Module 03
Subqueries & CTEs ~2h · hands-on
SQL · L3 Track · Module 03

Subqueries & CTEs
queries inside queries

Every SQL query returns a table. Which means a query can sit inside another query (as a single value, as a list to match against, as a whole derived table, or as a named, reusable building block). Maya just started as an analyst here, and her first week is one long lesson in composability: turning one hard question into a few easy ones stacked together, all the way up to a recursive walk of the org chart.

~2h · composition needs Modules 00–02 PostgreSQL 16
0
Section 00 · Orientation

A query's result is itself a table

By now you can SELECT columns, WHERE-filter rows (Module 00), JOIN tables together (Module 01), and GROUP BY + aggregate (Module 02). One property has quietly held all of that together: every SQL query returns a table. Tables go in, a table comes out. That is called the closure property, and it is the entire reason subqueries exist. It is also the idea Maya keeps reaching for as the questions her manager sends get harder.

If a query produces a table, and SQL operates on tables, then a query can stand anywhere a table is expected. A query that returns one row and one column behaves like a single value, so it can go where a value goes. A query that returns one column of many rows behaves like a list. A query that returns a full grid behaves like a table you can select from. A subquery (or inner query) is just a SELECT wrapped in parentheses and dropped into a spot in an outer query.

The schema Maya works with is small: a departments table, an employees table that points back at it, and an orders table. Tiny, but every pattern in this module shows up the moment a real request lands on her desk.

As a value

Returns one row, one column. Usable anywhere a literal like 100000 would go, typically in SELECT or WHERE.

As a list

Returns one column, many rows. Feeds IN (...), ANY, ALL. Asks "is this value among those?"

As a table

Returns a full grid. Lives in FROM as a derived table, or named up top as a CTE.

The win is composability. Instead of cramming a hard question into one tangled statement, you answer a simpler sub-question first, then build on its result. This module walks every place a subquery can live, the one trap that catches almost everyone (NOT IN with NULLs), and finishes with Common Table Expressions, the readable, named form that makes deep nesting unnecessary.

One rule, many shapes

There is really only one idea here: a result is a table, so a query can go wherever a table or value can. Scalar subqueries, IN-lists, EXISTS, derived tables, and CTEs are all just where you plug that result in. Hold the one rule and the syntax stops feeling like five separate features.

1
Section 01 · One value

Scalar subqueries

A scalar subquery returns exactly one row and one column, a single value. Because it is a value, you can use it anywhere a value is legal: in a WHERE comparison, in the SELECT list, even inside an expression. The classic use is comparing each row against an aggregate of the whole table, which a plain WHERE cannot do on its own.

Maya's first ticket: "who earns above the company average?" It has a chicken-and-egg shape. You need the average before you can compare. A scalar subquery computes that average first, then the outer query compares every row to it.

WHERE salary > (SELECT AVG(salary) FROM employees) column  ·  comparison  ·  scalar subquery → one value
earning above the company average
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);

-- the inner query first computes one number: AVG = 119166.67
-- then WHERE keeps rows whose salary beats it
 name  | salary
-------+---------
 Alice | 185000
 Bob   | 120000
 Carol | 135000

You can also drop a scalar subquery straight into the SELECT list to attach a computed value to every row. Here, each person's salary alongside the gap to the company average:

scalar subquery in the SELECT list
SELECT name,
       salary,
       salary - (SELECT AVG(salary) FROM employees) AS gap_to_avg
FROM employees;
It must return one value

A scalar subquery must return at most one row and one column. If it returns more than one row, PostgreSQL raises ERROR: more than one row returned by a subquery used as an expression. If it returns zero rows, the result is NULL (which can be surprising in arithmetic). When in doubt, make sure the inner query is an aggregate or has a key-equality filter that guarantees one row.

2
Section 02 · A list

Subqueries in WHERE with IN / NOT IN

When the inner query returns one column but many rows, it behaves like a list, and IN asks "is this value among those?" In Module 00 you used IN (10, 20) with a hand-written list. Now the list comes from another query, so it stays correct as the data changes.

Next on Maya's list: "which employees work in a department located in NYC?" The departments in NYC live in the departments table; the people live in employees. A subquery pulls the matching dept_ids, and IN filters employees against them.

IN with a subquery list
SELECT name, dept_id
FROM employees
WHERE dept_id IN (
        SELECT dept_id FROM departments
        WHERE location = 'NYC'
      );

-- inner query → the set of NYC dept_ids, e.g. {10, 20}
-- outer query → employees whose dept_id is in that set

NOT IN is the negation: "this value is among none of those." Read literally it seems obvious (think "employees not in any NYC department"), and for clean, NULL-free data it works. But NOT IN hides a trap so common it gets its own section next. For now, know that both forms exist and that the inner query must return a single column to feed them.

IN vs a join

You could also answer this with a JOIN to departments. The difference: IN returns each employee once regardless of how many NYC rows match, while a join can multiply rows if the right side is not unique. When you only want to test membership and not pull columns from the other table, IN (or EXISTS) is the cleaner intent.

3
Section 03 · Per-row tests

EXISTS, NOT EXISTS & correlated subqueries

So far the inner queries were self-contained: they could run on their own, once, and the outer query reused the result. A correlated subquery is different. It references a column from the outer row, so it cannot run standalone. Conceptually it re-runs once per outer row, with that row's values plugged in. It is the SQL equivalent of a nested loop.

EXISTS (subquery) is true if the subquery returns at least one row, false otherwise. It does not care what the rows contain, only whether any exist. That makes it the natural tool for "does a related row exist?" questions, and it pairs perfectly with correlation. Maya hits it when her manager asks which departments are actually staffed.

departments that HAVE at least one employee
SELECT d.dept_name
FROM departments d
WHERE EXISTS (
        SELECT 1                       -- value is irrelevant; only existence matters
        FROM employees e
        WHERE e.dept_id = d.dept_id     -- <- correlation: d.dept_id is the OUTER row
      );

For each department row d, the inner query asks "is there any employee whose dept_id equals this department's id?" The reference to d.dept_id is what makes it correlated. SELECT 1 is idiomatic: since EXISTS ignores the selected values, we select a cheap constant.

Flip it to NOT EXISTS for the opposite, "departments with no employees at all":

departments with NO employees
SELECT d.dept_name
FROM departments d
WHERE NOT EXISTS (
        SELECT 1 FROM employees e
        WHERE e.dept_id = d.dept_id
      );

EXISTS vs IN: same answer, different feel

Both can express membership tests, and a good planner often optimizes them similarly. The intuition:

IN (subquery)

Best when the inner list is small and independent. Builds the list once, then checks membership. Reads naturally as "value in this set."

EXISTS (correlated)

Best for "does a related row exist?" It can stop at the first match per outer row, and crucially it is NULL-safe where NOT IN is not.

Execution model

A correlated subquery is a conceptual nested loop: for each outer row, run the inner query with that row's values. In practice the planner may rewrite it into a hash or merge join, so "re-runs N times" is the mental model, not always the literal execution. But understanding it as a per-row test is what lets you reason about EXISTS correctly.

4
Section 04 · The big trap

The NOT IN + NULL trap

This is the most famous subquery gotcha, and it bites Maya hard. Her "employees not in any NYC department" query runs clean and returns nothing, even though she knows it should match people. It follows directly from the three-valued logic you met in Module 00. The rule: if the subquery feeding NOT IN returns even one NULL, the whole NOT IN returns no rows, silently, with no error. The query looks right, runs fine, and gives the wrong answer.

Why? x NOT IN (a, b, NULL) is defined as x <> a AND x <> b AND x <> NULL. That last comparison, x <> NULL, is unknown, never true. And true AND unknown collapses to unknown, which WHERE treats as not-true. So every row fails the test, even rows that clearly should match.

the bug, employees NOT in any NYC dept
-- Suppose one departments row has a NULL dept_id (bad data, or an outer join upstream).
-- This returns ZERO rows even though NYC clearly excludes some people.
SELECT name
FROM employees
WHERE dept_id NOT IN (
        SELECT dept_id FROM departments   -- if ANY dept_id here is NULL...
      );                                  -- ...the whole NOT IN becomes unknown → 0 rows

There are two robust fixes. The preferred one is to use NOT EXISTS, which uses a per-row existence test and is immune to the NULL collapse. The alternative is to filter the NULLs out of the subquery explicitly.

two correct fixes
-- FIX 1 (preferred): NOT EXISTS is NULL-safe
SELECT e.name
FROM employees e
WHERE NOT EXISTS (
        SELECT 1 FROM departments d
        WHERE d.dept_id = e.dept_id
      );

-- FIX 2: strip NULLs from the subquery before NOT IN sees them
SELECT name
FROM employees
WHERE dept_id NOT IN (
        SELECT dept_id FROM departments
        WHERE dept_id IS NOT NULL     -- guard against the trap
      );
Common gotcha

"Your NOT IN (subquery) returns nothing and you swear it should match rows. What happened?" The answer is almost always: the subquery contains a NULL. Because x <> NULL is unknown, the chained AND collapses to unknown for every row. Note IN with a NULL is not symmetric. It can still return matches; only NOT IN is poisoned. Default to NOT EXISTS and you sidestep the whole problem.

5
Section 05 · Quantifiers

ANY & ALL

ANY and ALL let you combine a comparison operator (>, <, =, …) with a whole list from a subquery. They answer "compared to some of these?" versus "compared to all of these?"

FormTrue when…Equivalent to
x > ANY (sub)x beats at least one valuex > MIN(sub)
x > ALL (sub)x beats every valuex > MAX(sub)
x = ANY (sub)x equals at least one valuex IN (sub)
x <> ALL (sub)x differs from every valuex NOT IN (sub)

So IN is just sugar for = ANY, and NOT IN is <> ALL, which is exactly why NOT IN inherits the same NULL trap from the previous section. The genuinely useful ones are the inequalities: "earns more than everyone in department 20" is a clean > ALL.

paid more than every employee in dept 20
SELECT name, salary
FROM employees
WHERE salary > ALL (
        SELECT salary FROM employees WHERE dept_id = 20
      );
-- dept 20 salaries = {135000, 98000}; > ALL means > 135000 → Alice (185000)
ALL and the empty set

A subtle edge: > ALL (empty subquery) is true for every row (there is nothing to fail against), while > ANY (empty) is false. And like NOT IN, > ALL with a NULL in the list goes unknown. In practice most people reach for MAX/MIN instead because the intent is clearer, but recognizing ANY/ALL is fair game.

6
Section 06 · A whole table

Subqueries in FROM: derived tables

When a subquery returns a full grid of rows and columns, you can put it in the FROM clause and select from it as if it were a real table. This is a derived table (or inline view). It is how you filter or join against an aggregated result, something a plain WHERE cannot do, because aggregates do not exist until after grouping.

Maya's manager wants the budget picture: "which departments have an average salary above 100,000?" First aggregate per department, then filter on that average. The aggregation is the inner table; the filter wraps around it.

filter on a per-department average
SELECT t.dept_id, t.avg_sal
FROM (
        SELECT dept_id, AVG(salary) AS avg_sal
        FROM employees
        GROUP BY dept_id
     ) AS t                       -- the derived table MUST be aliased: t
WHERE t.avg_sal > 100000;

The inner query produces a small table of (dept_id, avg_sal) rows; the outer query treats t like any table and filters it. You could write this with HAVING (Module 02), and for one level of aggregation that is cleaner. Derived tables earn their keep when you need to aggregate, then join or aggregate again on the result.

Must alias the derived table

PostgreSQL requires every subquery in FROM to have an alias, the ) AS t part. Forget it and you get ERROR: subquery in FROM must have an alias. Even if you never reference the alias by name, the parser demands one. This is a constant tripwire; make aliasing the derived table a reflex.

7
Section 07 · Named & readable

CTEs with WITH

By Friday Maya's derived-table query works, but a teammate reviewing it has to read it inside-out: start at the deepest parentheses and unwind outward. A Common Table Expression (CTE) fixes that. With the WITH keyword you give a subquery a name up top, then refer to it by name below, so the query reads top-down, like defining a variable before using it.

Here is the exact section-6 query rewritten as a CTE. Same result, but the intent is named and the structure is flat:

the derived-table query, as a CTE
WITH dept_avg AS (
       SELECT dept_id, AVG(salary) AS avg_sal
       FROM employees
       GROUP BY dept_id
)
SELECT dept_id, avg_sal
FROM dept_avg                      -- reference the CTE by name, like a table
WHERE avg_sal > 100000;

Read it as a sentence: "with a table called dept_avg defined as the per-department averages, select from it where the average exceeds 100,000." The logic that was buried in nested parentheses now has a label and lives at the top.

When CTEs beat nested subqueries

Readability

Named, top-down structure. Each step has a label that documents intent, with no inside-out unwinding.

Reuse

Postgres lets you reference a CTE multiple times in the same query. A derived table you would have to repeat.

No deep nesting

Chain several CTEs with commas, as in WITH a AS (…), b AS (… from a …), instead of stacking parentheses.

chaining CTEs, each builds on the last
WITH dept_avg AS (
       SELECT dept_id, AVG(salary) AS avg_sal
       FROM employees GROUP BY dept_id
),
high_paying AS (                  -- second CTE references the first
       SELECT dept_id FROM dept_avg WHERE avg_sal > 100000
)
SELECT e.name, e.salary
FROM employees e
JOIN high_paying h ON e.dept_id = h.dept_id;
CTE vs temp table

A CTE exists only for the duration of the single statement; it is not stored. A temporary table persists for the session and can be indexed and reused across statements. Reach for a temp table only when you genuinely need to materialize a result and hit it many times; for one query, a CTE is lighter and self-documenting. Modern Postgres does not always materialize CTEs, so they are not an optimization fence the way they once were.

8
Section 08 · The showcase

Recursive CTEs: walking the org chart

Maya's last task of the week is the one that scared her: build the org chart. Here is where subqueries do something nothing else can, traverse a hierarchy. The employees table has a self-referencing manager_id, so each person points at their boss. To produce the full management chain ("who reports up to whom, and how many levels deep?") you follow that chain an unknown number of steps. A recursive CTE does exactly that.

A WITH RECURSIVE CTE has three parts, joined by UNION ALL:

1 Anchorthe starting rows: here, the top boss (manager_id IS NULL), Alice
2 UNION ALLstack each recursive batch onto the results so far
3 Recursiveemployees whose manager appeared in the PREVIOUS level
✓ Terminationstops automatically when the recursive part returns no new rows
org chart with depth, from the top down
WITH RECURSIVE org AS (
    -- ANCHOR: the top of the tree (no manager)
    SELECT emp_id, name, manager_id, 1 AS depth
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- RECURSIVE: people whose manager is already in 'org'
    SELECT e.emp_id, e.name, e.manager_id, o.depth + 1
    FROM employees e
    JOIN org o ON e.manager_id = o.emp_id   -- join back to the CTE itself
)
SELECT depth, emp_id, name, manager_id
FROM org
ORDER BY depth, emp_id;

 depth | emp_id | name  | manager_id
-------+--------+-------+------------
     1 |      1 | Alice |     (null)     -- anchor
     2 |      2 | Bob   |          1     -- reports to Alice
     2 |      3 | Carol |          1
     2 |      6 | Frank |          1
     3 |      4 | Dan   |          3     -- reports to Carol
     3 |      5 | Eve   |          3

Maya traces the machine by hand. The anchor finds Alice (depth 1). The recursive member then joins employees to the rows just produced: who reports to Alice? Bob, Carol, Frank, all depth 2. Next pass, who reports to those? Dan and Eve report to Carol, so depth 3. The pass after that finds nobody new, so the recursion terminates. UNION ALL piles every batch into the final org table. The chart she dreaded falls out in eight lines.

Why UNION ALL, and a safety net

It is UNION ALL (not UNION) because each level is genuinely new rows. Deduplicating every pass would be wasteful and can hide legitimate results. Termination is automatic: when the recursive query yields zero new rows, it stops. But beware cycles. If the data had a manager loop (A manages B manages A), the recursion would never end. Guard real hierarchies with a depth cap (WHERE o.depth < 100) or Postgres's CYCLE clause.

9
Section 09 · Practice

Hands-on: do this now

Use the same three-table schema from Module 00 (recreate it if needed) in any Postgres playground or local psql. Write each query before peeking at a hint, and predict the rows out loud first.

  • Scalar subquery: list every employee earning above the company average. Then add a gap_to_avg column showing how far above (or below) each sits.
  • IN: find all employees whose department is located in 'NYC' using a subquery against departments. Then rewrite the same intent as a JOIN and confirm the row counts match.
  • EXISTS: list departments that have at least one employee. Then flip to NOT EXISTS for departments with none.
  • The trap: insert a departments row with a NULL dept_id, then run a NOT IN (SELECT dept_id FROM departments) query. Watch it return zero rows, then fix it two ways (NOT EXISTS, and filtering NULLs).
  • > ALL: find employees paid more than everyone in department 20. Confirm it matches > (SELECT MAX(salary) FROM employees WHERE dept_id = 20).
  • Derived table: select departments whose average salary exceeds 100,000 using a subquery in FROM. Deliberately omit the alias once to see the error, then add it.
  • CTE: rewrite the previous query with WITH. Then chain a second CTE that lists the employees in those high-paying departments.
  • Recursive CTE: build the org chart with a depth column from Alice down. Then modify the anchor to start at Carol (emp_id = 3) and produce only her subtree.
Predict, then run

For the recursive task especially, write out the levels on paper before running: anchor first, then each pass, the way Maya did. When your hand-traced tree matches the query output, you understand recursion rather than just copying syntax. That gap between prediction and result is the lesson every time.