CTE or Temp Table: Choosing Where an Intermediate Result Lives

The choice of CTE or temp table comes down to one question: do you want that intermediate result computed once and stored, or just named? A CTE gives a query a name. A temp table keeps the rows. They are not the same thing, and neither is always faster.

A lunch thermos retaining one prepared meal for repeated servings

A CTE is a name, not a saved result

A junior developer once asked me, “Is a CTE faster than a temp table?” I get this question a lot. The honest answer is that a CTE does not store anything. It is a name for a query inside one statement.

Use that name twice and SQL Server may run the underlying query twice. It may not. The execution plan tells you which, so you have to look. Let me build a small case where you can see it.

The block below creates 20,000 rows in 100 groups. Each group gets a total, which is the intermediate result we care about.

DROP TABLE IF EXISTS #StoredGroupTotals;
DROP TABLE IF EXISTS #IntermediateSource;

CREATE TABLE #IntermediateSource
(
    ItemId int PRIMARY KEY,
    GroupId int NOT NULL,
    Amount decimal(19,2) NOT NULL
);

INSERT #IntermediateSource
SELECT value, value % 100, CONVERT(decimal(19,2), value % 37)
FROM GENERATE_SERIES(1, 20000);

Reference the CTE twice

This query compares each group’s total with the next group’s total. That needs the grouped result twice, once as a and once as b. I turn on STATISTICS IO and STATISTICS PROFILE so the numbers show up next to the results. In SSMS you can also press Ctrl+M and read the actual plan.

SET STATISTICS IO ON;
SET STATISTICS PROFILE ON;

WITH GroupTotals AS
(
    SELECT GroupId, SUM(Amount) AS TotalAmount
    FROM #IntermediateSource
    GROUP BY GroupId
)
SELECT a.GroupId, a.TotalAmount, b.TotalAmount AS NextGroupTotal
FROM GroupTotals AS a
LEFT JOIN GroupTotals AS b ON b.GroupId = a.GroupId + 1
ORDER BY a.GroupId;

The profile has two Hash Match aggregates and two Clustered Index Scans of the source. Each scan returns 20,000 rows in one execution. The Messages tab agrees: the source table shows a scan count of 2. The grouping ran twice.

Native SSMS Properties showing a Clustered Index Scan, actual total rows 20000, and 1 execution
The first scan of the source reads and returns 20,000 rows in one execution.
Native SSMS Properties showing a second Clustered Index Scan, actual total rows 20000, and 1 execution
The second scan does the same work again: 20,000 rows, one execution.

Store the result once

Now do the grouping one time and keep the 100 rows in a temp table. I add a unique clustered index on GroupId, because the join looks rows up by that column. The final query is the same join as before.

SELECT GroupId, SUM(Amount) AS TotalAmount
INTO #StoredGroupTotals
FROM #IntermediateSource
GROUP BY GroupId;

CREATE UNIQUE CLUSTERED INDEX CX_StoredGroupTotals ON #StoredGroupTotals (GroupId);

SELECT a.GroupId, a.TotalAmount, b.TotalAmount AS NextGroupTotal
FROM #StoredGroupTotals AS a
LEFT JOIN #StoredGroupTotals AS b ON b.GroupId = a.GroupId + 1
ORDER BY a.GroupId;

The source is scanned once to build the temp table. The join then scans the 100 stored rows once and seeks into them 100 times. Those seeks return 99 rows in total, because the last group has no next group.

Native SSMS Properties showing a Clustered Index Seek, actual total rows 99, and 100 executions
The seek on the stored summary executes 100 times and returns 99 rows in total.
Native SSMS Properties showing a Clustered Index Scan, actual total rows 100, and 1 execution
The scan of the stored summary reads and returns its 100 rows in one execution.
What each one really does

Add up the whole bill

Here is the part people skip. Add up the logical reads in the Messages tab for everything the temp table version did. In my run it read more pages in total than the CTE version, because those 100 seeks are not free. With only 100 summary rows, there was little to save.

So count the whole cost: create, fill, index, query, and cleanup. Compare that with the CTE on your own data, using your real group counts.

How I choose

I start with a CTE when the result is used once and the query reads well. I try a temp table when the result is used several times, when later steps benefit from an index, or when the optimizer guesses badly on a complex statement. A temp table also gives the next statement real row counts to plan with.

It is not free, though. Temp tables cost writes, and they can trigger recompiles. Revisit the choice when the data size or the reuse count changes.

The last block turns the statistics output off and removes the temp tables.

SET STATISTICS PROFILE OFF;
SET STATISTICS IO OFF;

DROP TABLE IF EXISTS #StoredGroupTotals;
DROP TABLE IF EXISTS #IntermediateSource;

Next time someone says one is always faster, ask to see the plan.

A CTE is not a saved result, it is a name that may run again.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL System Table, SQL Table Operation, Table Partitioning, Temp Table
Previous Post
SQL SERVER – Steps to Identify with Odd and Even Rows
Next Post
SQL Server Maintenance Techniques: A Comprehensive Guide to Keeping Your Server Running Smoothly

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.