SET vs SELECT vs DECLARE: does it matter how you assign T-SQL variables?
I benchmarked seven ways of declaring and assigning T-SQL variables on SQL Server 2025 - almost 3,000 timed runs. The keyword barely matters; the statement count does, and mostly at compile time.
Every SQL Server team has the argument eventually. Someone writes:
DECLARE @a INT;
DECLARE @b INT;
SET @a = 1;
SET @b = 2;
and someone else “fixes” it in review to:
DECLARE @a INT = 1, @b INT = 2;
Is that a performance fix, or just style? I got curious enough to measure it properly: seven ways of getting to the same end state - a set of declared, assigned variables - benchmarked on SQL Server 2025 across variable counts from 1 to 64, a dozen data types, and string lengths from 1 to 4,000 characters. Almost 3,000 timed runs.
The short version: the keyword you use barely matters. The number of statements does - and mostly at compile time, not execution time. Here’s the data.
The seven contenders
Same end state, seven roads there:
| # | Method | Shape |
|---|---|---|
| 1 | Separate declares | DECLARE @v0 int; DECLARE @v1 int; … |
| 2 | Combined declare | DECLARE @v0 int, @v1 int, …; |
| 3 | Combined declare + inline assign | DECLARE @v0 int = 1, @v1 int = 2, …; |
| 4 | Separate declares + inline assign | DECLARE @v0 int = 1; DECLARE @v1 int = 2; … |
| 5 | Declare, then SET each | DECLARE …; SET @v0 = 1; SET @v1 = 2; … |
| 6 | Declare, then SELECT each | DECLARE …; SELECT @v0 = 1; SELECT @v1 = 2; … |
| 7 | Declare, then one combined SELECT | DECLARE …; SELECT @v0 = 1, @v1 = 2, …; |
Notice the real axis hiding in that table: methods 3 and 7 do all the assignments in one statement; methods 4, 5, and 6 use one statement per variable. Keep that in mind - it’s the whole story.
How do you measure something this small?
A single DECLARE costs well under a microsecond - far less than SQL Server’s clock can
even resolve. SYSUTCDATETIME() on this host only ticks about once a millisecond, so if
you time one execution you just read back zero. You never measure one statement; you
measure a great many and divide.
That job falls to a small T-SQL script - the benchmark harness. For each of the seven methods it does three things:
- Run the batch in a tight loop for a fixed slice of real time - about 400 ms - then count how many times it managed to run and divide. For the cheap methods that’s hundreds of thousands of iterations inside those 400 ms, which is plenty to average out to a stable per-run number. (400 ms is just the wall-clock budget: elapsed time on the clock on the wall, not CPU time.)
- Repeat the whole thing eight times and keep the fastest. The entire test matrix is measured from start to finish eight separate times - eight passes - with the order reshuffled each pass. This ran on a shared box, and a busy neighbour can only ever make a run slower, never faster, so the fastest of the eight passes is the closest thing to the method’s true, undisturbed cost.
- Subtract an empty batch. The loop itself isn’t free - there’s bookkeeping and the
cost of dispatching each batch to the engine. So the same loop is timed around the
smallest batch that can exist, a single
DECLARE @z BIT;that does no real work, and that time is subtracted from every result. What’s left is the cost of the assignment work alone, with the measuring scaffolding removed.
That’s the short version, and it’s enough to read the numbers below. The why behind each choice - why the fastest run beats the average, how a recompile is forced on demand, and the traps that quietly ruined the first attempt - is its own write-up: How to benchmark something that takes a microsecond.
Every method is timed in two modes, because they answer different real-world questions:
- Cached - the plan is compiled once and reused. This is what a hot stored procedure pays on every call.
- Recompile - a plan-cache miss is forced on every iteration. This is what ad-hoc batches, or procs hit by recompilation, pay.
One honesty note before the numbers: this ran on a shared 4-core box, so absolute values are comparative, not a hardware spec. Trust the ordering and the scaling; your microseconds will differ.
Result 1: declaring is free, assigning is not
First, declarations alone - no assignment yet, just bringing the variables into
existence. Cached, net of the no-op baseline, here’s what declaring N ints costs:
| Variables | One combined DECLARE | N separate DECLAREs |
|---|---|---|
| 1 | ~0 µs | ~0 µs |
| 8 | 17 µs | 21 µs |
| 16 | 34 µs | 45 µs |
| 32 | 76 µs | 98 µs |
| 64 | 166 µs | 209 µs |
At one variable the cost is indistinguishable from the measurement floor; even at 64 -
far more than any hand-written procedure declares - it’s a couple hundred microseconds,
whether you write one DECLARE or sixty-four. If you’ve ever wondered whether SQL Server
“does work” for each DECLARE at runtime: technically yes, but never enough to notice.
Declaration placement is a readability decision, full stop.
Assignment is where actual cost appears. And once you compare the five assigning methods, a clean pattern emerges. Cached, at 8 variables (net cost per batch):
| Method | Statements | µs/batch |
|---|---|---|
Combined DECLARE with inline assigns | 1 | 105 |
One combined SELECT | 1 | 119 |
Separate DECLAREs with inline assigns | 8 | 160 |
SET per variable | 8 | 168 |
SELECT per variable | 8 | 192 |
The two one-statement forms win; the three N-statement forms cluster together behind
them; and the gap widens as the variable count grows. SET versus single-variable
SELECT - the version of this argument people actually have - is a near-tie at every
variable count. The keyword is not the lever. The statement count is.
Result 2: compilation is where the choice actually bites
The cached numbers above are small - for a handful of variables you’re talking a few microseconds of difference per call, which in almost any real procedure is rounding error. The recompile numbers are not small.
When the batch has to be parsed and compiled each time (net cost, by variable count):
| Method | 1 var | 4 vars | 16 vars | 64 vars |
|---|---|---|---|---|
Combined DECLARE + inline assigns | 30 µs | 61 µs | 227 µs | 1.68 ms |
One combined SELECT | 40 µs | 75 µs | 246 µs | 1.81 ms |
Separate DECLAREs + inline assigns | 31 µs | 88 µs | 340 µs | 2.32 ms |
SET per variable | 31 µs | 89 µs | 350 µs | 2.26 ms |
SELECT per variable | 37 µs | 104 µs | 402 µs | 2.51 ms |
Three things to read off that table:
- Compilation costs far more than execution. At 64 variables, the compile-every-time forms cost 1.7-2.5 milliseconds per batch, where the same batches run from a cached plan in a few hundred microseconds at most - the compiler is doing several times more work than the runtime ever will.
- The one-statement forms compile meaningfully cheaper, and the margin grows with variable count - at 64 variables, separate-statement forms cost ~40-50% more to compile than the combined ones.
- The ordering is identical to the cached case. There’s one rule, not two: fewer statements, less work - it just matters ~10× more when compilation is in play.
So if you have ad-hoc batches, frequently-recompiled procedures, or generated SQL with dozens of variables, statement count is a real (if modest) cost. If your procs compile once and run a million times, it’s noise.
Result 3: data types and value lengths - nothing to see
I also swept a dozen data types (bit through datetime2 to uniqueidentifier) and string
lengths from 1 to 4,000 characters. The honest summary: on this host, any per-type or
per-length effect was smaller than the noise floor of a shared machine. Fixed-width
types cluster together with no convincing size ordering, and varchar versus nvarchar
was a wash. Whatever effect exists, it’s small compared to the statement-count effect -
which is itself small compared to anything your queries actually do. I’ll re-run this
sweep on a quiet dedicated box before claiming more.
The asterisk that matters more than any of this: SELECT’s silent semantics
One non-performance difference deserves more attention than every number above. When assigning from a query:
SELECT @status = Status FROM dbo.Orders WHERE CustomerId = @cust;
if that query returns multiple rows, SELECT silently assigns the last one and
moves on. The equivalent SET with a subquery raises an error. And if the query returns
zero rows, SELECT leaves the variable’s previous value untouched - it doesn’t set
it to NULL - which is a classic source of stale-value bugs in loops.
A correctness footgun is worth more than 50 microseconds of compile time. If your assignment query could ever return anything other than exactly one row, that - not performance - should decide the syntax.
What I’d actually recommend
- For a handful of variables in a stored procedure: write whatever reads clearest. The cached-plan difference is single-digit microseconds; no human will ever observe it.
- If you’re assigning many variables at once, prefer one statement - a combined
DECLARE @a INT = 1, @b INT = 2, …or a singleSELECT @a = 1, @b = 2, …. It’s never slower, it’s meaningfully cheaper to compile, and it’s usually more readable anyway. - In generated SQL or ad-hoc-heavy workloads, statement count is a real lever, because you pay compilation every time.
- Choose
SETvsSELECTon semantics, not speed - they cost the same per statement, but they behave differently on zero and multiple rows. - And the meta-lesson: this entire effect is microseconds. If a procedure is slow, its variable declarations are not why. Look at the queries, the indexes, and the execution plan - that’s where the milliseconds live.
Re-verified on bare metal (August 2026)
In Result 3 I promised to re-run the type and length sweeps “on a quiet dedicated box
before claiming more.” Done — the whole matrix, on a
bare-metal lab with isolated, clock-clamped cores,
where the noop floor drops from ~43 to ~30 µs and trial spreads collapse from tens of
microseconds to ±1–4 µs. The headline survives untouched: the statement count is the
lever — the five assignment shapes finish in exactly the published order (combined
DECLARE 86 µs → one SELECT 101 → separate DECLAREs 130 → SETs 133 → separate
SELECTs 153 for 8 variables), and separate declarations still cost ~1.3× combined at
every variable count.
The quiet box also settles Result 3 properly. Declaring a string variable is
length-independent — flat within ±1 µs from 1 to 4,000 characters; that’s now a
measured null, not a shrug. Assigning a literal is not: for 16 variables, ~200 µs
per batch up to 100 characters, then roughly 2× at 1,000 characters (374–425 µs) —
an effect that was drowned by host noise the first time. At 4,000 characters something
genuinely strange and type-dependent happens (nvarchar assignment cost collapses,
varchar explodes ~5×, both rock-steady across trials) — that’s reproducible on both
hosts, unexplained, and now its own investigation rather than a hand-wave.
The benchmark harness - batch generator, timing loop, no-op baseline subtraction, and
the full result set (374 configurations × 8 passes) - is reproducible end-to-end against
a disposable SQL Server 2025 container. The scripts and captured CSVs are public:
github.com/dkultasev/tsql-benchmarks
(./run.sh full rebuilds the whole dataset from scratch). Want this kind of measurement
done on your own workload? Get in touch.
Need help with something like this?
DBM takes on focused SQL Server engagements — performance, migrations, and CI/CD.