AppliedAIPrep logoAppliedAI/Prep
🗄️ Data & SQL Engineering
Foundational

CTEs and Subqueries

A CTE (the WITH clause) names an intermediate result so a query reads as a top-to-bottom pipeline instead of nested subqueries. The skill is knowing when a subquery should be correlated versus uncorrelated, when a recursive CTE is the right tool for hierarchies and graphs, and when a CTE acts as an optimization fence that blocks the planner. Applied-AI interviews probe it because refactoring a tangled nested query into a readable, correct pipeline is a daily data-engineering task.

TL;DR: A CTE (WITH name AS (...)) names an intermediate result so a query reads as a linear pipeline instead of subqueries nested three deep. Know the splits: a correlated subquery references the outer row and runs per-row (often slow), an uncorrelated one runs once; a recursive CTE walks hierarchies and graphs; and in some engines a CTE is an optimization fence that is materialized and blocks the planner from pushing predicates through it.

A CTE is a named pipeline stage

A common table expression names a query result you can reference later in the same statement. The payoff is readability: instead of reading a query inside-out, you read it top to bottom, each WITH block a stage. Compare a nested mess to the staged version.

-- nested: read inside-out
SELECT * FROM (
  SELECT customer_id, SUM(amount) AS total
  FROM (SELECT * FROM orders WHERE status = 'paid') p
  GROUP BY customer_id
) t WHERE total > 1000;

-- staged: read top-down
WITH paid AS (
  SELECT * FROM orders WHERE status = 'paid'
),
totals AS (
  SELECT customer_id, SUM(amount) AS total FROM paid GROUP BY customer_id
)
SELECT * FROM totals WHERE total > 1000;

Same plan in most modern engines, but the second is reviewable. Naming each stage is also how you debug: select from any CTE in isolation to see what it produces.

Correlated vs uncorrelated subqueries

An uncorrelated subquery is self-contained and the engine evaluates it once: WHERE region_id IN (SELECT id FROM regions WHERE active). A correlated subquery references a column from the outer query, so conceptually it runs once per outer row:

SELECT o.id
FROM orders o
WHERE o.amount > (
  SELECT AVG(amount) FROM orders WHERE customer_id = o.customer_id  -- correlated
);

The o.customer_id reference ties the inner query to each outer row. Planners often rewrite correlated subqueries into joins or window functions, but not always, and a correlated subquery in a SELECT list over a large table is a classic latency surprise. The usual fix is a window function (AVG(amount) OVER (PARTITION BY customer_id)) or a join to a pre-aggregated CTE, both of which compute the per-group value once.

Recursive CTEs for hierarchies and graphs

A recursive CTE has an anchor (the seed rows) and a recursive member that references the CTE itself, joined back until it returns no new rows. This is the standard way to walk an org chart, a category tree, or a bill-of-materials.

WITH RECURSIVE reports AS (
  SELECT id, manager_id, name, 1 AS depth
  FROM employees WHERE id = 42          -- anchor: the root
  UNION ALL
  SELECT e.id, e.manager_id, e.name, r.depth + 1
  FROM employees e JOIN reports r ON e.manager_id = r.id  -- recurse
)
SELECT * FROM reports;

For a graph with cycles, track the visited path (an array of ids) and exclude already-seen nodes, or set a depth cap, otherwise the recursion never terminates. The same shape does shortest-path-style traversals and transitive closure.

When a CTE is an optimization fence

Here is the trap. In some engines a CTE is materialized: the planner computes it once into a temp result and cannot push a later WHERE predicate down into it.

EngineDefault CTE behavior
PostgreSQL 12+Inlined when referenced once and non-recursive; MATERIALIZED keyword forces a fence
PostgreSQL <12Always materialized (a hard fence)
SQL Server, MySQL 8Generally inlined, treated like a derived table
Spark SQL, BigQueryInlined; optimizer sees through them

So a CTE that filters a billion-row table and is then filtered again downstream may scan the whole table if it is fenced. The fix in old Postgres is to inline manually (use a subquery) or move the predicate up; in new Postgres, drop the MATERIALIZED hint. Materialization is occasionally what you want: a CTE referenced five times that is expensive to recompute should be a fence so it runs once.

Why interviewers probe this

They want to see you turn a nested subquery knot into a clean pipeline and know the performance footguns. The strong-answer move: refactor inside-out subqueries into named CTE stages, replace a per-row correlated subquery with a window function or a join to an aggregated CTE, and reach for WITH RECURSIVE the moment you hear "hierarchy" or "tree." The follow-up they hold back: "you have this CTE referenced once and the query is slow, why?" The expected answer is the optimization-fence question, does your engine materialize it, and is a predicate failing to push down.

Common misconceptions

  • "CTEs are always faster than subqueries." Readability differs; performance is usually identical, except when a CTE is a materialization fence that blocks predicate pushdown.
  • "A CTE is a temp table." It is scoped to one statement and (in inlining engines) often not materialized at all.
  • "Correlated subqueries are just slow subqueries." They reference the outer row and run per-row; the fix is a window function or join, not a tweak.
  • "Recursive CTEs are dangerous because of infinite loops." Only on cyclic graphs; track visited nodes or cap depth and they terminate.

Key takeaways

  • Use CTEs to turn inside-out nested subqueries into a top-down, reviewable pipeline.
  • Correlated subqueries reference the outer row and run per-row; prefer a window function or a join to a pre-aggregated CTE.
  • WITH RECURSIVE (anchor + recursive member) walks hierarchies and graphs; guard cycles with a visited set or depth cap.
  • A CTE can be an optimization fence (materialized, no predicate pushdown); know your engine's default before blaming the query.
LEARNING LAB1 of 4

Check yourself before an interviewer does. Answer from memory first.

A CTE is referenced exactly once and your query is still scanning a billion-row table. What's the likely cause?

RELATED CONCEPTS
PRACTICE THIS IN REAL QUESTIONS
COMPANIES THAT ASSUME THIS
NEXT IN DATA & SQL ENGINEERINGNULLs and Three-Valued Logic