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.
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 Ordersreturns one number, and the outer query compares each row to it.
Rewrite it with
WITH AvgOrder AS (
SELECT AVG(Amount) AS a
FROM Orders)
SELECT OrderID, Amount
FROM Orders, AvgOrder
WHERE Orders.Amount > AvgOrder.aThe AvgOrder name now sits at the top, and you reference it like a table.
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.
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.
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.
Show each customer with their order count
SELECT Name,
(
SELECT COUNT(*)
FROM Orders o
WHERE o.CustomerID = c.CustomerID) AS OrderCount
FROM Customers cThis keeps the aggregation next to the row it describes.
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.
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 <= 3Each CTE names a clear stage.
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.
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.
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.
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.
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.
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.
Add the next level: UNION ALL
SELECT e.EmployeeID, e.ManagerID, e.Name
FROM Employees e
JOIN Org o
ON e.ManagerID = o.EmployeeIDThe query joins Employees to the growing Org CTE until no new rows appear.
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.
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.
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.
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.
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.
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.
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

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 MonthlyThis returns one row per customer per month with that month's spend.
Wrap it
SELECT CustomerID, COUNT(DISTINCT Mon) AS Months, SUM(Spend) AS Total
FROM Monthly
GROUP BY CustomerIDCustomers with Months >= 2 are your repeat buyers for the period.
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 DESCOn 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.
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.
If yes, CTEs keep each stage readable. Stack them with WITH and read top to bottom instead of tracing parentheses inward.
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.



