Parameter Sniffing with sp_prepare and sp_prepexec

Introduction
The prepare and execute model is not specific to SQL Server. Almost all relational database engines provide a similar feature, though there are many implementation differences. Most usage originates with client connection drivers and APIs like ODBC/OLEDB/JDBC.
The general idea is to speed up the repeated execution of similar statements by performing much of the static work (e.g. parsing, validating, binding to database objects) once, reducing the cost of future executions.
Whether this pays off depends on how much work is saved in future executions compared with the extra costs involved in preparing. Iâm being deliberately vague here because the specifics depend on the driver and client options, even when the database engine remains the same. Some implementations go as far as creating a final executable plan at the preparation stage. Iâm only going to cover T-SQL usage in this article.
sp_prepare
and sp_execute
Originally undocumented for T-SQL, sp_prepare
and sp_execute
have been present in the documentation since at least SQL Server 2008, with T-SQL example code added for the 2012 release. The current version is much the same, but includes the optional statement terminator and more T-SQL examples.
When used from T-SQL, sp_prepare
takes a (usually parameterized) statement as input and returns an integer handle in an output parameter:
sp_prepare handle OUTPUT, params, stmt, options
The documentation doesnât currently mention that the input handle
value should be set to NULL
, unless youâre intending to replace an existing valid handle with a new prepared statement.
Benefits
The primary benefit of sp_prepare
is that it saves time and network bandwidth.
Once prepared, future calls to sp_execute
need provide only the integer handle instead of sending the entire text to the server. It is also slightly quicker for the server to locate any existing cached plan using the handle than it is to hash the text (and any parameter definitions) to perform a regular cache lookup.
Handles are scoped to the connection and represent an entry in an internal hash table in memory that points to the saved text and parameter specifications, among other internal details. Handles remain active until sp_unprepare
is called or the connection is terminated.
Contrary to the documentation, sp_unprepare
does not discard any execution plan cached by sp_prepare
. It simply releases the memory used by the hash table entry identified by the supplied handle. The plan cache is entirely separate from the prepared statement handles structure and functions completely independently.
Sniffing
You may notice that sp_prepare
doesnât provide a way for parameter values to be provided, only the parameter definitions. Particular parameter values will be provided later with each sp_execute
call.
This means SQL Server cannot sniff parameter values at the time sp_prepare
is called. If a plan is created and cached, it will be based on average density or other âeducatedâ (to varying degrees) guesses, exactly as if local variables had been used, OPTIMIZE FOR UNKNOWN
was specified, or parameter sniffing was disabled via any other mechanism.
I say âIf a plan is createdâ above because calling sp_prepare
doesnât always result in a plan being cached.
Plan production
When a prepared statement is created from a driver or API, plan creation behaviour depends in part on whether the client requests a deferred prepare or not. It seems likely that a client explicitly requesting a deferred prepare would not contact SQL Server at all at the preparation stage.
In any case, there is no way to specify the deferred prepare option from T-SQL, so we get a default behaviour.
Curiously, the modern default for API calls is to defer the construction of a prepared execution plan until execute is called. For T-SQL, the default is the opposite: SQL Server always creates a plan during sp_prepare
when that is possible.
This behaviour is why youâll often see people say that sp_prepare
doesnât sniff parametersâit canât, because the plan is produced before parameter values are known.
An exception
The caveat, of course, is in the phrase âwhen that is possibleâ. While it is always possible in principle to produce a plan at the prepare stage, this is not what SQL Server does.
Simple, single-SELECT
statements will always produce a plan during prepare. More complex, multi-SELECT
batches will not. In the latter case, plan production is deferred to the first execute call (when any parameters will have specific values).
So, how can we tell if sp_prepare
cached a plan?
Well, one obvious way is to check the plan cache after the call; however, there is another, more immediate, indication.
If sp_prepare
produces a plan, it produces an empty result set. This is to provide the client with the shape of the results it should expect, including column data types and lengths.
If sp_prepare
doesnât produce a plan, no empty result set is produced.
Demo 1: Prepared plan
Using any SQL Server version and the AdventureWorks sample database, the following script demonstrates the common case of sp_prepare
generating a plan (without sniffing parameter values):
-- Ensure no plans in cache
DBCC FREEPROCCACHE;
GO
DECLARE @handle integer = NULL;
EXEC sys.sp_prepare
@handle OUTPUT,
@params =
N'@Search nvarchar(50)',
@stmt =
N'
SELECT
UniqueProducts = COUNT_BIG(DISTINCT P.ProductNumber)
FROM Production.Product AS P
WHERE
P.[Name] LIKE @Search;
';
-- Cached plan (after prepare, before execute)
SELECT
P.usecounts,
P.size_in_bytes,
P.cacheobjtype,
P.objtype,
S.[text]
FROM sys.dm_exec_cached_plans AS P
CROSS APPLY sys.dm_exec_sql_text(P.plan_handle) AS S
WHERE
S.[text] LIKE '%@Search%'
AND S.[text] NOT LIKE N'%sp_prepare%';
-- Execute the prepared statement for a particular parameter value
EXECUTE sys.sp_execute @handle, @Search = N'%';
EXECUTE sys.sp_unprepare @handle;
That produces a blank result set from the sp_prepare
call, details of the cached plan, and the (later) results produced by sp_execute
:
The runtime (actual) execution plan displayed in SSMS shows an estimate was produced for the LIKE
predicate based on a fixed guess of 9% of the 504 rows in the Product table:
The plan root node shows only a runtime parameter value:
In summary, a plan based on a complete guess was produced by sp_prepare
before parameter values were known. No parameter sniffing took place.
Demo 2: Deferred prepare
This version of the demo requires SQL Server 2012 or later.
To get a deferred prepare, it might seem inconvenient to include multiple SELECT
statements in the text when we only want one set of results. This is true, but luckily there are ways to write an extra SELECT
without returning results.
A short form of the trick is to use a dummy DECLARE
that assigns a value to a local variable. This is enough to make SQL Server think multiple SELECT
statements are present. You could also write the shorthand assignment out as separate DECLARE @var
and SELECT @var=
statements if you prefer. Using SET @var=
works too.
The only thing different in the script below is the (unused) variable declaration with assignment. Just declaring the variable or having more than one statement is not sufficientâthe important part is the hidden SET/SELECT
assignment.
-- Ensure no plans in cache
DBCC FREEPROCCACHE;
GO
DECLARE @handle integer = NULL;
EXEC sys.sp_prepare
@handle OUTPUT,
@params =
N'@Search nvarchar(50)',
@stmt =
N'
DECLARE @Defer bit = ''true''; -- NEW!
SELECT
UniqueProducts = COUNT_BIG(DISTINCT P.ProductNumber)
FROM Production.Product AS P
WHERE
P.[Name] LIKE @Search;
';
-- Cached plan (after prepare, before execute)
SELECT
P.usecounts,
P.size_in_bytes,
P.cacheobjtype,
P.objtype,
S.[text]
FROM sys.dm_exec_cached_plans AS P
CROSS APPLY sys.dm_exec_sql_text(P.plan_handle) AS S
WHERE
S.[text] LIKE '%@Search%'
AND S.[text] NOT LIKE N'%sp_prepare%';
-- Execute the prepared statement for a particular parameter value
EXECUTE sys.sp_execute @handle, @Search = N'%';
EXECUTE sys.sp_unprepare @handle;
Running that script does not produce an empty result set from sp_prepare
. No plan is cached before sp_execute
is called.
The empty result set shown below indicates the lack of a cached plan after the sp_prepare
call:
The runtime plan as shown in SSMS is produced during the sp_execute
call (the prepare was deferred):
Notice the estimates are exactly correct. The @Search
parameter value N'%'
was sniffed and used to optimize the cached plan for that value.
The supplied LIKE
predicate with a sniffed N'%'
value matches all non-null rows, which means all 504 rows in the table given that the Name column is defined as NOT NULL
. This cardinality estimate was the basis for the optimizer choosing an index scan over a seek.
The plan root node shows both compile and runtime parameter values, further proof that parameter sniffing occurred:
Sniffed seek
If we change the sp_execute
@Search parameter to N'[A-P]%'
and run the whole script again, we get a dynamic seek plan again but with estimates from the string columnâs trie tree statistics instead of a guess:
The estimate of 374.4 rows is very close to the 366 rows produced at runtime.
The important thing is that the extra SET/SELECT
variable assignment caused SQL Server to defer plan creation from sp_prepare
to sp_execute
. This means the parameter value was available and could be sniffed to optimize the cached plan.
This behaviour was introduced in SQL Server 2012. Before that, sp_prepare
(called from T-SQL) never resulted in a deferred prepare and parameters were never sniffed.
sp_prepare
will cause parameters to be sniffed when deferred prepare is used.
You can also cause sp_execute
to create a sniffed plan by manually evicting a prepared plan cached by sp_prepare
before calling sp_execute
, but thatâs hardly convenient or efficient in the general case.
sp_prepexec
The sp_prepexec
documentation says:
Prepares and executes a parameterized Transact-SQL statement.
sp_prepexec
combines the functions ofsp_prepare
andsp_execute
.
That is an accurate statement, but âcombining the functionsâ does not mean the equivalent behaviour is identical in all respects.
For one thing, sp_prepexec
does not return the extra empty result set produced by sp_prepare
(thereâs no need since the combined call is going to return real results anyway).
The secondâmost importantâdifference is that sp_prepexec
is called with specific parameter values so it can always sniff parameters for the cached execution plan.
A statement that does not cause a deferred prepare with sp_prepare
(producing a plan based on guesses) will sniff parameter values when called using sp_prepexec
.
-- Ensure no plans in cache
DBCC FREEPROCCACHE;
GO
DECLARE @handle integer = NULL;
-- Combine prepare and execute in a single call
EXEC sys.sp_prepexec
@handle OUTPUT,
@params =
N'@Search nvarchar(50)',
@stmt =
N'
SELECT
UniqueProducts = COUNT_BIG(DISTINCT P.ProductNumber)
FROM Production.Product AS P
WHERE
P.[Name] LIKE @Search;
',
@Search = N'%';
EXECUTE sys.sp_unprepare @handle;
That produces the sniffed scan plan optimized for @Search = N'%'
without the variable assignment trick needed to get a deferred prepare from separate sp_prepare
and sp_execute
calls.
sp_prepexec
sp_prepexec
always sniffs parameter values.
Final Thoughts
That might leave you wondering why anyone would use sp_prepare
in preference to sp_prepexec
.
- It might be convenient to prepare statements for an application early, before you know exactly when or where they will be used.
- You might want advance details about the shape of the result set.
- You might know a deferred prepare would not occur and you donât want parameter sniffing. There are better ways to accomplish this in modern versions of SQL Server.
Regardless, youâll rarely use any of these system extended procedures instead of sp_executesql
from T-SQL, but you never know when these details might help you understand a problem or execution plan.
Iâll cover some less well-known details about sp_executesql
in my next article.
Bear in mind that undocumented details can change at any time, and other drivers and APIs use the prepare and execute model differently from T-SQL.
The brief demos in this article are unrealistic in that they only call sp_execute
once. Prepared statements can only pay off if they are executed many times. Further executions would produce plans with generally different parameter runtime values compared with the original parameter compiled value shown at the root node of execution plans.
Thanks for reading!