← All cheatsheets
Data Analyst · #040 · September 1, 2026 · 2 min read

When should you use a CTE instead of a subquery?

What CTEs and subqueries are each for, the four-question decision rule, the CTE + ROW_NUMBER top-N interview pattern, and the NOT IN trap that silently returns zero rows.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

Nested queries age badly: the query that made sense inside-out in March is unreadable by June. Here is when to name a step and when an inline lookup is fine. The print-ready A4 PDF is at the bottom.

The CTE

  • WITH step AS (…): name a step.
  • SELECT … FROM step: the query reads top-down.
  • WITH a AS (), b AS (): chain steps, one per transformation.

The subquery

  • (SELECT …) AS t: inline, used once.
  • IN (SELECT id …): filter by a lookup.
  • EXISTS (SELECT 1 …): a presence check.

Choosing

  • Used twice? CTE.
  • One-line lookup? Subquery.
  • Debugging? CTE: run each step on its own.
  • Three levels deep? CTE, always.

The top-N interview pattern

WITH ranked AS (
  SELECT customer_id, order_date,
         ROW_NUMBER() OVER (
           PARTITION BY customer_id
           ORDER BY order_date DESC) AS rn
  FROM orders)
SELECT * FROM ranked WHERE rn = 1;

You cannot filter on ROW_NUMBER in the same SELECT that defines it. The CTE is what makes WHERE rn = 1 legal, and it wins on readability over the self-join answer.

Performance

  • CTE vs subquery: engines inline both, plans usually match.
  • AS MATERIALIZED: force an expensive CTE to compute once.
  • Correlated subquery: reruns once per outer row, the actual trap.
  • WITH RECURSIVE exists for hierarchies; you will rarely need it.

The trap: NOT IN meets NULL

You wroteWhat happensWrite instead
NOT IN (SELECT ref_id …)one NULL = zero rows backNOT EXISTS (SELECT 1 …)
(SELECT SUM(…)) per rowreruns for every rowJOIN a grouped CTE
4 subqueries nestedunreadable in a weekone CTE per step

One NULL in the subquery and the whole result is empty, silently. And the next person to read your query is you, in six months: name the step.

Frequently asked questions

What is a CTE in SQL?
A Common Table Expression: WITH step AS (SELECT ...) names an intermediate result so the rest of the query can read it like a table. The query then reads top-down instead of inside-out, and you can chain several with WITH a AS (), b AS (). It exists for readability first.
Are CTEs faster than subqueries?
Usually neither is faster: modern engines inline both into the same plan, so pick the readable one. If a CTE is expensive and used several times, AS MATERIALIZED forces it to be computed once. The real performance trap is the correlated subquery, which reruns once per outer row.
How do I get the top N rows per group in SQL?
Put ROW_NUMBER() OVER (PARTITION BY group ORDER BY metric DESC) in a CTE, then SELECT ... WHERE rn = 1 outside it. You cannot filter on a window function in the same SELECT that defines it, so the CTE is what makes the filter legal. It also beats a self-join on readability.
Why does NOT IN return no rows?
Because of NULL. If the subquery returns even one NULL, x NOT IN (1, 2, NULL) is never true under SQL three-valued logic, so the whole result comes back empty with no error and no warning. NOT EXISTS (SELECT 1 ...) treats NULLs the way you meant.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

More cheatsheets