Temp Table vs Table Variable vs CTE in SQL Server

Every T-SQL developer hits this fork in the road eventually: you need to stash some intermediate result set inside a stored procedure, and you have three built-in options. You can create a #temp table, declare a @table variable, or wrap the logic in a common table expression (CTE — a named, temporary result set defined with a WITH clause that exists only for the duration of the query that references it). Most tutorials give you a syntax rundown and a vague "it depends" and call it a day.

Diagram comparing temp table vs table variable vs CTE performance in SQL Server

That's not enough to make good decisions in a production stored procedure that runs thousands of times a day. The real answer to the temp table vs table variable vs CTE SQL Server question depends on row counts, whether the optimizer can generate accurate statistics, how the object interacts with the query plan cache, and what happens when a transaction rolls back. This article walks through each of those angles with concrete examples so you can pick correctly instead of guessing — using the query optimizer behavior current in SQL Server 2022, SQL Server 2025, Azure SQL Database, and Azure SQL Managed Instance, which all share the same engine for these three constructs.

Overview: What Each Option Actually Is

A temp table (#TempTable for session-scoped, ##TempTable for global) is a real table created in tempdb, the system database SQL Server uses for temporary storage. It behaves like any other table — it supports indexes, constraints, primary keys, and statistics (metadata about data distribution that the query optimizer uses to estimate row counts). It's visible to the current session and any nested procedures called from it. When created inside a stored procedure, it is automatically dropped when that procedure finishes executing, whereas a temp table created in an ad-hoc batch persists until the session ends or it is explicitly dropped.

A table variable (@TableVariable) is also backed by tempdb under the hood, despite the common misconception that it lives entirely in memory. It's declared like a variable, scoped to the batch, function, or stored procedure in which it's defined, and it goes out of scope automatically — no explicit DROP needed.

On any current platform — SQL Server 2022, SQL Server 2025, Azure SQL Database, or Azure SQL Managed Instance — a table variable's row estimate is better than the flat guess it used to be. All of these support table variable deferred compilation (available since SQL Server 2019 under database compatibility level 150, and unchanged under SQL Server 2022's default level of 160 and SQL Server 2025's default level of 170), which defers compiling the statement that references the table variable until it first actually runs, so the optimizer can see the real row count instead of guessing. That's a cardinality fix, not a statistics fix — table variables still don't get column-level distribution statistics (histograms) the way temp tables do, which I'll get into below.

It also creates a plan-caching problem a lot like parameter sniffing, even though a table variable isn't a parameter: the plan is compiled and cached based on whatever row count the table variable happened to have on that first execution, and later executions reuse that same plan even if the row count is wildly different. A stored procedure that first runs with 5 rows in the table variable and later runs with 100,000 will keep reusing the 5-row plan until something forces a recompile.

If you're troubleshooting an older instance: table variables originally had no statistics at all and always assumed exactly 1 row. SQL Server 2014's 100-row default estimate is a common point of confusion here — it applied only to multi-statement table-valued functions, not to plain table variables.

Regardless of version, one practical mitigation is worth knowing: adding OPTION (RECOMPILE) to the statement that queries the table variable forces SQL Server to compile that statement using the actual row count at execution time instead of reusing a cached estimate. It doesn't give the optimizer a histogram, but it does eliminate the stale-plan-reuse problem described above — at the cost of paying a compilation cost on every execution, which matters for statements that run very frequently.

A CTE is not a physical storage object at all. It's a named subquery — syntactic sugar that lets you reference a result set by name within a single statement. It has no persistent storage, no indexes, and no statistics of its own. Unlike some other database engines, SQL Server does not materialize a non-recursive CTE to cache and reuse its result across multiple references — the optimizer inlines the CTE definition into the surrounding query, so the underlying logic is evaluated again every time the CTE is referenced.

The choice matters most in stored procedures that process meaningful row counts, run repeatedly, or need to survive across multiple statements or transaction boundaries — which rules out CTEs immediately for a lot of real-world scenarios, since a CTE can't be referenced outside the single statement it's attached to.

Key Differences

Here's a direct comparison of the three across the dimensions that actually affect production behavior.

Dimension Temp Table (#temp) Table Variable (@table) CTE
Storage location tempdb, physical tempdb, physical None — inline or materialized as part of the parent query
Scope Session (visible to nested procs) Batch/procedure only Single statement only
Statistics Full column and index statistics, auto-updated No distribution statistics (histograms), ever. On SQL Server 2022, SQL Server 2025, Azure SQL Database, and Azure SQL Managed Instance, deferred compilation gives the optimizer the table variable's actual cardinality instead of a fixed guess None — relies entirely on the base tables it references
Indexes Explicit indexes, including nonclustered Only via inline PRIMARY KEY / UNIQUE constraints at declaration Not applicable
Transaction/rollback behavior Participates in transactions; rolled back with ROLLBACK Does not participate in transactions; changes survive ROLLBACK Not applicable — no state to roll back
Recompilation Can trigger statement-level recompiles when row counts cross specific thresholds Does not trigger recompiles based on data volume changes Recompiled with the parent query each execution
Recursive queries Not supported directly Not supported directly Supported (WITH cte AS (... UNION ALL ...))
Reusability across batches Yes, within the session No, scoped to a single batch/procedure No, single use

The single most misunderstood row in that table is "Statistics." A lot of older blog posts state flatly that table variables never get statistics and always assume one row. That's outdated advice for the platforms most teams are running today — deferred compilation, covered above, means the optimizer usually isn't flying blind anymore. Don't swing too far the other way, though: it's still a cardinality estimate rather than a real statistics object, there's no column-level histogram behind it, and — as noted above — that estimate can go stale the moment row counts shift between executions. Table variables are meaningfully better than they used to be. They're still not equivalent to temp tables for estimation purposes.

Performance: Where Cardinality Estimation Actually Bites You

The performance conversation almost always comes down to cardinality estimation — the optimizer's guess at how many rows a given step in the query plan will produce. Get that guess wrong and everything downstream, from join strategy to memory grant size, can be wrong too.

Temp tables carry real statistics. When you insert 500,000 rows into a #temp table and then join it to another table, SQL Server can (and in most configurations, will) auto-create statistics on the columns used in the join or WHERE clause. That gives the optimizer a genuinely informed row estimate, which usually leads it to pick the right join type — a hash join for large sets, a nested loop for small ones.

sql
-- Temp table: statistics get created, optimizer sees real row counts
CREATE TABLE #OrderStaging
(
    OrderId     INT PRIMARY KEY,
    CustomerId  INT NOT NULL,
    OrderTotal  DECIMAL(12,2) NOT NULL
);

INSERT INTO #OrderStaging (OrderId, CustomerId, OrderTotal)
SELECT o.OrderId, o.CustomerId, o.OrderTotal
FROM dbo.Orders AS o
WHERE o.OrderDate >= DATEADD(MONTH, -3, SYSUTCDATETIME());

CREATE NONCLUSTERED INDEX IX_OrderStaging_CustomerId
    ON #OrderStaging (CustomerId);

SELECT c.CustomerName, SUM(os.OrderTotal) AS TotalSpent
FROM #OrderStaging AS os
JOIN dbo.Customers AS c ON c.CustomerId = os.CustomerId
GROUP BY c.CustomerName;

If you check the actual execution plan for that join, the estimated row count next to the #OrderStaging scan should track reasonably close to the actual row count, because the optimizer has statistics to draw from. That's the whole advantage in one sentence: better estimates lead to better plan choices.

Now the same logic with a table variable:

sql
-- Table variable: historically no statistics, weaker row estimates
DECLARE @OrderStaging TABLE
(
    OrderId     INT PRIMARY KEY,
    CustomerId  INT NOT NULL,
    OrderTotal  DECIMAL(12,2) NOT NULL
);

INSERT INTO @OrderStaging (OrderId, CustomerId, OrderTotal)
SELECT o.OrderId, o.CustomerId, o.OrderTotal
FROM dbo.Orders AS o
WHERE o.OrderDate >= DATEADD(MONTH, -3, SYSUTCDATETIME());

SELECT c.CustomerName, SUM(os.OrderTotal) AS TotalSpent
FROM @OrderStaging AS os
JOIN dbo.Customers AS c ON c.CustomerId = os.CustomerId
GROUP BY c.CustomerName;

Structurally this looks nearly identical, but the plan the optimizer builds can differ meaningfully once the row count climbs. In practice, teams often see table variables handle small sets fine — a few hundred rows or fewer — and then start causing bad plan choices (usually nested loop joins where a hash join would be far cheaper) once row counts move into the tens of thousands.

There's no single universal row-count cutoff that applies to every schema and workload, so treat any specific number you read, including the ones in this article, as a starting point for your own testing with SET STATISTICS IO, TIME ON and the actual execution plan, not a hard rule.

CTEs don't have their own statistics because they aren't physical objects — the optimizer just inlines the CTE definition into the surrounding query and estimates based on the underlying base tables. That means a CTE's "performance" is really just the performance of the query it expands into.

If you reference the same CTE twice in one statement, SQL Server does not cache and reuse the result — it expands the CTE definition and evaluates the underlying logic separately for each reference, which can be a real cost trap for anything nontrivial inside the CTE body.

sql
-- Recursive CTE example: employee hierarchy traversal
WITH EmployeeHierarchy AS
(
    SELECT EmployeeId, ManagerId, EmployeeName, 0 AS HierarchyLevel
    FROM dbo.Employees
    WHERE ManagerId IS NULL

    UNION ALL

    SELECT e.EmployeeId, e.ManagerId, e.EmployeeName, eh.HierarchyLevel + 1
    FROM dbo.Employees AS e
    JOIN EmployeeHierarchy AS eh ON e.ManagerId = eh.EmployeeId
)
SELECT EmployeeId, EmployeeName, HierarchyLevel
FROM EmployeeHierarchy
OPTION (MAXRECURSION 100);

This is the one job a temp table or table variable genuinely can't do cleanly: recursive traversal of hierarchical data. The MAXRECURSION hint caps the recursion depth (default is 100; you can set it to any value from 1 to 32,767, or use 0 to remove the cap entirely, which is risky on bad data) and protects you from an infinite loop if the hierarchy has a cycle.

One nuance worth flagging if you're reading an actual execution plan: a recursive CTE's plan typically shows Index Spool or Lazy Spool operators, each carrying a WITH STACK property — that's how SQL Server implements the recursive iteration itself, not a sign that the optimizer is caching and reusing results the way a temp table's statistics would let it. A non-recursive CTE referenced multiple times still gets evaluated separately for each reference, as noted above; a spool showing up in a recursive CTE's plan isn't evidence that CTEs are generally materialized and cached.

On locking and tempdb contention: under heavy concurrent load, both temp tables and table variables write to tempdb, and both can contribute to contention on tempdb's system pages, particularly on instances where tempdb isn't configured with multiple data files. This is an instance-level tuning concern more than a construct-level one.

If you're seeing PAGELATCH waits tied to tempdb page allocation on SQL Server 2019 or later, running on-premises or on an Azure VM, it's worth enabling Memory-Optimized Metadata TempDB at the server level (ALTER SERVER CONFIGURATION SET MEMORY_OPTIMIZED TEMPDB_METADATA = ON;, which requires a SQL Server service restart to take effect), which moves temp table and table variable system metadata off disk pages and into memory-optimized structures.

Azure SQL Database sidesteps this specific contention pattern for standalone single databases, since each one gets its own isolated tempdb instead of sharing one across many databases. Databases placed in an elastic pool are the exception: every database in the pool shares one tempdb, though temporary objects created by one pooled database still aren't visible to the others in the pool. Azure SQL Managed Instance also shares a single tempdb across the whole instance.

One correction worth being explicit about: Memory-Optimized Metadata TempDB itself — the specific fix described above — currently isn't available on either Azure SQL Database or Azure SQL Managed Instance, according to Microsoft's own documentation. Treat it as an on-premises or Azure VM lever only, and re-check Microsoft's current documentation before assuming otherwise, since Azure service capabilities do change over time.

Developer Experience: Syntax, Tooling, and Transactional Behavior

Syntactically, temp tables and table variables both feel like ordinary CREATE TABLE / DECLARE plus standard DML. The friction shows up in edge cases. Table variables don't support ALTER TABLE after declaration, can't have nonclustered indexes added after creation (only inline at declaration time, and only in reasonably recent SQL Server versions), and don't support TRUNCATE TABLE. They also don't support SET IDENTITY_INSERT or DBCC CHECKIDENT — if a table variable's structure includes an IDENTITY column, there's no way to insert explicit values into it or reseed the counter, which is a common source of friction in ETL staging scripts that need that kind of control.

Temp tables support nearly everything a permanent table does, including adding indexes after the fact, running sp_rename, or checking existence with OBJECT_ID('tempdb..#TempTable') before creating it.

Transaction behavior is where a lot of developers get burned — usually in the opposite direction from what they expect. Table variables do not participate in explicit user transactions. If you modify a table variable inside a transaction and then hit a ROLLBACK, the changes to the table variable survive it. Temp tables behave the way you'd normally expect a table to behave: they fully participate in transactions, and a ROLLBACK undoes their modifications along with everything else in the transaction.

This difference is exactly why table variables show up so often in error-logging patterns — a stored procedure can catch a failure, roll back the real data changes, and still have a table variable full of diagnostic detail to inspect or persist afterward.

sql
BEGIN TRY
    BEGIN TRANSACTION;

    DECLARE @ErrorLog TABLE (ErrorMessage NVARCHAR(4000), LoggedAt DATETIME2);

    -- This insert survives the rollback below, because table variables don't participate in transactions
    INSERT INTO @ErrorLog (ErrorMessage, LoggedAt)
    VALUES ('Starting risky operation', SYSUTCDATETIME());

    UPDATE dbo.Accounts SET Balance = Balance - 1000000 WHERE AccountId = 42;

    -- Simulate a failure
    THROW 51000, 'Simulated failure for demonstration', 1;

    COMMIT TRANSACTION;
END TRY
BEGIN CATCH
    ROLLBACK TRANSACTION;

    -- @ErrorLog's insert survived the rollback, so we can still inspect it here
    SELECT ErrorMessage, LoggedAt, ERROR_MESSAGE() AS CapturedError
    FROM @ErrorLog;
END CATCH;

That transaction independence is a real, documented feature of table variables, not folklore — but keep it scoped to what it actually gives you. A table variable's scope still ends when the batch or procedure ends, so if you need the log entries to persist after the session is gone (say, for a permanent audit table other people query later), you still need a real table.

Reserve the table-variable pattern for capturing diagnostic detail you want to inspect or act on within the same procedure that just rolled back, not as a substitute for permanent logging.

CTEs have the smallest footprint of the three when it comes to tooling friction: no cleanup, no explicit scope management, and they read cleanly for anyone reviewing the query. The tradeoff is that they can't be indexed, can't hold intermediate state across statements, and encourage developers to nest CTE on top of CTE until the query becomes genuinely hard to reason about.

A CTE that's referenced multiple times or that wraps a window function is fine. A five-level-deep chain of CTEs each filtering the last is usually a sign the logic should be broken into a temp table with clear checkpoints instead.

Use Cases: Which One to Pick

For staging data inside a stored procedure that will be joined, filtered, or aggregated multiple times, and where row counts could reasonably reach the thousands or more, reach for a temp table. The ability to add a targeted index and get real statistics pays for itself the moment the optimizer has to make a nontrivial join decision.

For small lookup sets — configuration values, a short list of IDs passed in in bulk, a handful of rows used once and discarded — a table variable is a reasonable, low-ceremony choice. It avoids the small overhead of explicit DROP TABLE cleanup and keeps scope tightly bound to the procedure, which some teams prefer for readability.

For a single self-contained query, especially one that benefits from readable, named intermediate steps or that requires actual recursion (hierarchies, bill-of-materials structures, graph traversal within reasonable depth), a CTE is the right tool. Don't force a CTE to do a temp table's job just to avoid an extra few lines of CREATE TABLE syntax — if the same intermediate result needs to be queried multiple times or across statements, a CTE isn't built for that.

One area worth a specific mention: memory-optimized table variables, which use the In-Memory OLTP engine (a memory-optimized data storage engine distinct from regular tempdb-backed structures) rather than tempdb.

SQL Server, Azure SQL Database, and Azure SQL Managed Instance all share the same In-Memory OLTP implementation, but availability depends on your tier. On Azure SQL Database, it's supported on the Premium and Business Critical tiers; the Hyperscale tier also supports a subset of In-Memory OLTP objects, including memory-optimized table types and table variables, but not durable or non-durable memory-optimized tables. On Azure SQL Managed Instance, it's Business Critical only — the General Purpose tier doesn't support it at all.

These can meaningfully reduce memory allocation and latch contention in very high-throughput OLTP scenarios with extremely short-lived, small result sets. They come with real constraints, though: they require a memory-optimized filegroup to already be configured on the database, their structure can't be altered once declared, and they're a genuinely different commitment than a plain @table declaration. Reserve them for cases where tempdb contention on table variables is a measured, confirmed bottleneck in your environment, and validate the specific behavior against Microsoft's current documentation for your platform and tier before committing to it.

Limitations: What Each One Doesn't Do Well

Temp tables carry real overhead: explicit creation and cleanup, potential tempdb I/O for larger sets, and possible recompilation of the surrounding stored procedure if row counts vary wildly between executions, since the optimizer may decide the cached plan no longer fits.

They also don't automatically disappear at the end of a batch the way a table variable does. Inside a stored procedure this rarely matters, since the procedure-scoped temp table is dropped when the procedure finishes — but a #temp table created in an ad-hoc script or kept alive in a long-running session won't clean itself up, so it can linger for as long as that session stays open if you forget to drop it explicitly.

Table variables can't hold large volumes of data efficiently without solid cardinality estimation behind them, and don't support ALTER TABLE. They also carry a specific parallelism restriction: any statement that modifies a table variable — an INSERT, UPDATE, or DELETE against it — is forced to run with a serial execution plan for that statement, even on a server with plenty of cores available.

That restriction covers the whole statement, not just the write: an INSERT INTO @table SELECT ... forces the entire query serial, including the SELECT side gathering the source data, even if that source query would normally benefit from parallelism on its own. Statements that only read from a table variable aren't subject to that restriction and can go parallel like any other query.

For a large staging insert, that serial requirement alone can make a table variable noticeably slower than the equivalent #temp table. They're also easy to overuse out of habit once a developer learns "table variables are lighter," which isn't reliably true once volume grows.

CTEs can't be indexed, can't be reused across statements, and offer the optimizer no independent statistics — they inherit whatever estimation quality the base tables provide. Recursive CTEs, while powerful, can also produce surprisingly expensive plans on deep or wide hierarchies if MAXRECURSION isn't set thoughtfully and the anchor/recursive member logic isn't tightly filtered.

Conclusion

The temp table vs table variable vs CTE SQL Server decision isn't really about which one is "faster" in the abstract — it's about which one gives the optimizer the information it needs for your specific row counts and access pattern. Use temp tables when you need real statistics and indexing for meaningful data volumes, use table variables for small, short-lived sets where scope simplicity matters more than estimation accuracy, and use CTEs for single-statement readability or genuine recursion. You now have a concrete way to check which one is right for a given procedure: look at the actual execution plan, compare estimated versus actual row counts, and let that evidence — not habit — drive the choice. As a next step, run SET STATISTICS IO, TIME ON against your own slow stored procedure and compare the plan under each of the three approaches before committing to a rewrite.

Codingvila provides articles and blogs on web and software development for beginners as well as free Academic projects for final year students in Asp.Net, MVC, C#, Vb.Net, SQL Server, Angular Js, Android, PHP, Java, Python, Desktop Software Application and etc.

If you have any questions, contact us on info.codingvila@gmail.com