What a subquery and a CTE actually are

A subquery is a

SELECT statement wrapped in parentheses that runs inside another query. You will usually see it in a WHERE clause, a FROM clause, or as a calculated column in the SELECT list. A CTE, short for common table expression, is a named temporary result set defined with the WITH keyword at the top of your query. The CTE itself does not store data;

It exists only for the lifetime of that single statement. The core difference is structure: a subquery hides logic inside another clause, while a CTE lifts that logic to the top so you can name it and reuse it by name.

1
Write a subquery inside a WHERE clause

Suppose you want orders larger than the average order from the Orders table. Write

SELECT OrderID, Amount
FROM Orders
WHERE Amount > (
  SELECT AVG(Amount)
  FROM Orders)

The inner

SELECT AVG(Amount)
FROM Orders

returns one number, and the outer query compares each row to it.

2
Write the same logic as a CTE

Rewrite it with

WITH AvgOrder AS (
  SELECT AVG(Amount) AS a
  FROM Orders)
SELECT OrderID, Amount
FROM Orders, AvgOrder
WHERE Orders.Amount > AvgOrder.a

The AvgOrder name now sits at the top, and you reference it like a table.

Pro Tip

I recommend starting every complex query as a CTE. It is much easier to debug because you can copy the CTE body into a separate window and run it alone to confirm the row counts before wiring it into the next stage.

When a subquery is the better tool

Subqueries shine when you need a single scalar value or a short list inside one clause and you will not reuse it. They also map cleanly to filters like IN and EXISTS. If your logic is a one-liner, wrapping it in a CTE adds a WITH block that makes the query longer without adding clarity. Keep the subquery for the small, local jobs.

3
Use IN with a subquery for a membership filter

Find customers who placed an order

SELECT CustomerID, Name
FROM Customers
WHERE CustomerID IN (
  SELECT CustomerID
  FROM Orders)

The subquery returns a list of IDs, and IN checks membership for each row.

4
Use EXISTS to test for related rows

Find customers with at least one order over $500

SELECT Name
FROM Customers c
WHERE EXISTS (
  SELECT 1
  FROM Orders o
  WHERE o.CustomerID = c.CustomerID AND o.Amount > 500)

EXISTS stops scanning as soon as it finds one match, which is often faster than IN on large tables.

5
Use a subquery as a calculated column

Show each customer with their order count

SELECT Name,
  (
  SELECT COUNT(*)
  FROM Orders o
  WHERE o.CustomerID = c.CustomerID) AS OrderCount
FROM Customers c

This keeps the aggregation next to the row it describes.

Watch Out

A common mistake is using IN (subquery) when the subquery can return NULLs. If any returned row is NULL, some engines treat the whole IN test as unknown and you silently lose rows. Use NOT EXISTS instead of NOT IN to avoid that trap.

When CTEs make the query readable

CTEs win the moment you reference the same derived table more than once, or when the logic has three or more steps. They let you name each intermediate result, which makes the query read like a recipe. In my experience, a monthly report built from four nested subqueries is far easier to maintain as four CTEs stacked with WITH.

6
Build a multi-step pipeline with CTEs

To rank products by category, define each step

WITH CategorySales AS (
  SELECT CategoryID,
  ProductID,
  SUM(Amount) AS Rev
  FROM Sales
  GROUP BY CategoryID,
  ProductID),
  Ranked AS (
  SELECT *,
  RANK() OVER (PARTITION BY CategoryID ORDER BY Rev DESC) AS r
  FROM CategorySales)
SELECT *
FROM Ranked
WHERE r <= 3

Each CTE names a clear stage.

7
Reference a CTE more than once

If you need both the total and the top region, define RegionRevenue once

WITH RegionRevenue AS (
  SELECT Region, SUM(Amount) AS Rev
  FROM Sales
  GROUP BY Region)
SELECT *
FROM RegionRevenue
WHERE Rev = (
  SELECT MAX(Rev)
  FROM RegionRevenue)

Without a CTE you would repeat the aggregation twice.

8
Chain CTEs to replace nested subqueries

Replace deep nesting with a flat chain: WITH A AS (SELECT ... FROM Sales), B AS (SELECT ... FROM A), C AS (SELECT ... FROM B) SELECT ... FROM C. The reader follows A to B to C instead of jumping inward through parentheses.

Why deeply nested subqueries get dangerous

A subquery inside a subquery inside a FROM clause is hard to read and hard to test. You cannot easily run the inner block on its own, and a typo in level three can silently change the whole result. This is where most code review comments come from, so flatten early.

9
Spot the nesting depth

Count parenthesis levels in your FROM. If you see SELECT ... FROM (SELECT ... FROM (SELECT ... FROM Orders) x) y, you have three levels. That is a clear signal to convert the inner two into CTEs.

10
Convert the innermost block first

Take the deepest SELECT ... FROM Orders GROUP BY and promote it to a CTE named Base. Then rewrite the next level to read FROM Base. Repeat upward until the main query is flat and each stage has a name.

11
Verify each CTE returns expected rows

Run SELECT COUNT(*) FROM Base and compare it to a manual check before wiring it into the next CTE. This will break if the counts do not match, so catch the mismatch early rather than at the final SELECT.

Recursive CTEs for hierarchy data

CTEs can reference themselves, which a plain subquery cannot do directly. This is the standard way to walk a manager-employee tree or a product category hierarchy. The syntax uses WITH RECURSIVE and two parts: an anchor member and a recursive member joined by UNION ALL.

12
Write the anchor member

Start with the top-level rows

WITH RECURSIVE Org AS (
  SELECT EmployeeID, ManagerID, Name
  FROM Employees
  WHERE ManagerID IS NULL)

This returns the roots of the tree, the people with no manager.

13
Write the recursive member

Add the next level: UNION ALL

SELECT e.EmployeeID, e.ManagerID, e.Name
FROM Employees e
JOIN Org o
ON e.ManagerID = o.EmployeeID

The query joins Employees to the growing Org CTE until no new rows appear.

14
Cap recursion to avoid infinite loops

Add a depth column that increments each round, for example level + 1, and stop at level <= 10. A cycle in your data will otherwise run forever and time out the query, so the guard is not optional.

Do CTEs and subqueries perform differently

On most modern engines, a CTE is not a performance trick. The optimizer usually inlines it the same way it would a subquery. The win is readability, not speed. Real performance comes from indexes and how you write the filter, not from the syntax you pick.

15
Check the plan with EXPLAIN

Run EXPLAIN SELECT ... FROM Sales WHERE Region IN (SELECT Region FROM TopRegions). Compare the plan to the CTE version. If both show a full table scan, the syntax was not your bottleneck.

16
Index the join and filter columns

Add an index on Sales.Region and Orders.CustomerID. I recommend indexing the columns you join and filter on before rewriting query structure; that change alone often cuts run time by 30 to 60 percent on a million-row table.

17
Avoid SELECT * inside CTEs

Select only the columns you use. A CTE that pulls twenty columns you never reference wastes memory and can block some optimizers from simplifying the plan into a tighter seek.

Mistakes that break subqueries and CTEs

Most failures are logic errors, not syntax. Watch for column name collisions, duplicate CTE names, and referencing a CTE before it is defined. None of these throw a helpful message, so they are worth memorizing.

18
Do not reuse a CTE name

You cannot define WITH A AS (...), A AS (...) in the same query. This will break with a duplicate name error. Give each CTE a unique, descriptive name tied to what it produces.

19
Qualify ambiguous columns

When two CTEs both have a Region column, write RegionRevenue.Region not just Region. A common mistake is letting the engine guess and returning the wrong table's value silently, which corrupts the totals.

20
Reference CTEs in definition order

A CTE can only be used by CTEs defined after it. You cannot reference Next before Current in WITH Current AS (...), Next AS (SELECT ... FROM Current). Order them top to bottom as the data flows.

Worked example: finding monthly repeat buyers

SQL editor showing a CTE query that counts repeat buyers from the Orders table by month

Here is a real report. The business asks: which customers ordered in two different months, and what is their total spend? We use a CTE because we reference the monthly aggregation twice, once to count months and once to total spend.

WITH Monthly AS (
  SELECT CustomerID,
  DATE_FORMAT(OrderDate, '%Y-%m') AS Mon,
  SUM(Amount) AS Spend
  FROM Orders
  GROUP BY CustomerID, DATE_FORMAT(OrderDate, '%Y-%m'))
SELECT *
FROM Monthly
21
Define the monthly aggregation

This returns one row per customer per month with that month's spend.

22
Count distinct months per customer

Wrap it

SELECT CustomerID, COUNT(DISTINCT Mon) AS Months, SUM(Spend) AS Total
FROM Monthly
GROUP BY CustomerID

Customers with Months >= 2 are your repeat buyers for the period.

23
Filter and sort the final result

Full query

WITH Monthly AS (...),
  Agg AS (
  SELECT CustomerID,
  COUNT(DISTINCT Mon) Months, SUM(Spend) Total
  FROM Monthly
  GROUP BY CustomerID)
SELECT *
FROM Agg
WHERE Months >= 2
ORDER BY Total DESC

On a 50,000-row Orders table this returns 3,200 repeat buyers in about 0.4 seconds with an index on CustomerID.

How to choose in practice

Use a simple three-question rule. If the logic is a single value or a one-time filter, use a subquery. If you reuse a result, chain steps, or need recursion, use a CTE. When in doubt, write the CTE; you can always collapse it into a subquery later.

24
Ask: will I reference this twice

If yes, name it as a CTE so the engine and your teammates see one definition. If no, a subquery keeps the statement shorter and the intent local.

25
Ask: are there three or more steps

If yes, CTEs keep each stage readable. Stack them with WITH and read top to bottom instead of tracing parentheses inward.

26
Ask: do I need recursion

Only CTEs can self-reference with WITH RECURSIVE. Pick a recursive CTE for trees, org charts, and category hierarchies that point back to themselves.

Next steps to practice

Take one report you wrote last month with three nested subqueries and rewrite it as CTEs. Then run both versions through EXPLAIN and compare the row estimates. You will see the numbers match and the CTE version is easier to hand to a teammate. Pair this habit with window functions and date aggregation, which stack naturally on top of CTEs.

Side by side comparison of a nested subquery and an equivalent CTE in a SQL editor