Temp Tables vs Table Variables in SQL Server: When Each One Wins
Temp Tables vs Table Variables in SQL Server
T-SQL gives you two ways to stash an intermediate result set: temp tables (#orders_stage) and table variables (@orders_stage). They look interchangeable in a 10-row example and behave completely differently at 100,000 rows. The persistent myth is "table variables live in memory, temp tables live in tempdb." Both live in tempdb. The real differences are statistics, transactions, compilation, and parallelism.
The Basics
-- Temp table: visible to the session (and inner batches/procs), dropped
-- when the session ends or on explicit DROP
CREATE TABLE #orders_stage (
order_id int PRIMARY KEY,
customer_id int NOT NULL,
amount decimal(10,2) NOT NULL
);
-- Table variable: scoped to the batch/procedure, cleaned up automatically
DECLARE @orders_stage table (
order_id int PRIMARY KEY,
customer_id int NOT NULL,
amount decimal(10,2) NOT NULL
);Both support PRIMARY KEY and UNIQUE constraints declared inline. Since SQL Server 2014 you can also declare non-unique indexes inline on both:
DECLARE @t table (
id int PRIMARY KEY,
customer_id int NOT NULL INDEX ix_cust NONCLUSTERED
);The scoping rules differ in a way that matters for stored procedures: a #temp table created in a procedure is visible to procedures it calls; a table variable never leaks outside its own batch. And you can't ALTER a table variable or create indexes on it after the fact -- everything must be in the declaration.
The Difference That Dominates: Statistics
Temp tables have column statistics. Table variables have none. The optimizer builds histograms on #temp table columns exactly as it does for regular tables, so joins and filters against temp tables get real cardinality estimates.
Table variables have no statistics at all. On compatibility levels below 150, the optimizer assumed a table variable contained 1 row -- regardless of whether it held 5 or 5 million. One row means nested loops joins, serial plans, and tiny memory grants. At 5 million actual rows, that plan is a disaster: the classic symptom is a query that's fast with a #temp table and hangs with a @table variable, with the actual plan showing estimated 1 / actual 5,000,000.
SQL Server 2019 (compatibility level 150) improved this with table variable deferred compilation: statements referencing a table variable compile on first execution, using the actual row count at that moment. That fixes the 1-row absurdity but does not add statistics -- the optimizer knows how many rows, not their distribution. Selective predicates against table variable columns still get guessed. And the count captured at first compile sticks for the cached plan, which is its own parameter-sniffing-style trap when row counts vary between executions.
Rule of thumb that survives all version changes: beyond a few hundred rows, or anywhere the plan quality matters, use a temp table.
Transactions: the Behavior That Surprises Everyone
Table variables are not affected by transaction rollback:
DECLARE @audit table (msg varchar(100));
CREATE TABLE #audit (msg varchar(100));
BEGIN TRANSACTION;
INSERT INTO @audit VALUES ('table variable row');
INSERT INTO #audit VALUES ('temp table row');
ROLLBACK;
SELECT COUNT(*) FROM @audit; -- 1: survived the rollback
SELECT COUNT(*) FROM #audit; -- 0: rolled backThis is a feature, not a bug, when used deliberately: capturing diagnostic/audit rows inside a transaction that might roll back (for example, logging what an error handler saw before ROLLBACK -- see our transactions guide). It's a nasty surprise when you assumed transactional consistency for staged data.
The flip side: because table variables do minimal logging and don't participate in rollback, they take fewer locks and can be marginally cheaper for tiny, hot workloads.
Parallelism
Queries that modify table variables (INSERT/UPDATE/DELETE into @t) cannot use a parallel plan for the modification -- a documented product limitation. Reading from a table variable can be parallelized; loading one cannot. Temp tables have no such restriction, and SELECT ... INTO #t is one of the fastest ways to materialize a large intermediate set (minimally logged, parallelizable).
For large loads this alone decides the question.
Recompiles: the Case FOR Table Variables
Temp tables aren't free. Creating, populating, and dropping #temp tables inside frequently-called procedures triggers recompilations (schema and statistics changes on the temp table invalidate the plan), and under very high concurrency, tempdb allocation contention. Table variables cause fewer recompiles precisely because they have no statistics to change.
For a procedure called thousands of times per second that stages a handful of rows, a table variable is genuinely the better choice. This is the workload table variables were designed for.
Also in the table variable column: table-valued parameters (passing row sets into procedures) must be table variables -- there's no temp-table equivalent:
CREATE TYPE order_id_list AS TABLE (order_id int PRIMARY KEY);
GO
CREATE PROCEDURE process_orders @ids order_id_list READONLY
AS
SELECT o.* FROM orders AS o
JOIN @ids AS i ON i.order_id = o.order_id;Decision Guide
| Situation | Use |
|---|---|
| More than a few hundred rows | #temp table |
| Joining the intermediate set to other tables | #temp table (statistics drive join choice) |
| Large load performance matters | #temp table (SELECT INTO, parallel) |
Need to ALTER, add indexes later, or share with called procs | #temp table |
| A few rows, hot procedure, recompile-sensitive | @table variable |
Must survive ROLLBACK (audit/diagnostics) | @table variable |
| Table-valued parameter | @table variable (only option) |
| In a function | @table variable (temp tables not allowed) |
Common Mistakes
| Mistake | Reality |
|---|---|
| "Table variables are in memory, temp tables are on disk" | Both are backed by tempdb; both can live entirely in buffer pool memory |
| Staging 100k+ rows in a table variable | No statistics → bad joins; no parallel load. Use #temp |
Assuming @table rows roll back | They survive ROLLBACK by design |
| Expecting 2019 deferred compilation to fix everything | It fixes the row count, not the missing histograms |
| Blanket-replacing table variables with temp tables in hot OLTP procs | Recompile overhead can make things worse for tiny row counts |
Mako connects to SQL Server with AI-powered autocomplete, which helps when sketching staged queries against temp tables before committing them to a procedure. Try it free at mako.ai.
Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.