Search This Blog

Sunday, September 24, 2023

Enable PowerShell Remoting

Enable PowerShell Remoting

I use PowerShell quite a lot to manage servers, especially SQL Servers. So I need to be able to run PowerShell commands remotely.  Fortunately I don't have to worry about this much on Windows Server 2012 and up, because the PowerShell remoting is enabled by default. On the other hand, if an organization deems PowerShell, and PSRemoting in particular, as a security risk and decides to disable it on purpose. that obivoulsly is a different matter. 

And, any earlier versions of Window Server and all versions of the Windows client operating systems (Home Pro etc.), PowerShell remoting is turned off by default. 

To enable PSRemoting:

Connect / RDP into the computer.

Launch the PowerShell with elevated privileges (Run As Administrator).









Run the command:

Enable-PSRemoting –Verbose and –Confirm


I added the -Verbose and -Confirm parameters because by default, the Enable-PSRemoting runs silently with no output whatsoever.  

Here is a link to the Microsoft article for more information on Enable-PSRemoting command:


Enable-PSRemoting


And, if you’re working in a workgroup environment, for example, at home, with no domain controller to handle the security and identity, you need to add list of computers you trust. Here is the article that explains how-to, and troubleshooting other potential issues:

Troubleshoot-PowerShell-Remoting


Once it is enable, here is a basic example how to send command to the remote computer:

Invoke-Command -ComputerName Servername -ScriptBlock {'Hello world'}

That's all nice. But how do you check whether it is already enabled or not? PowerShell doesn't provide a direct way to check whether PSRemoting is enabled or not. Indirectly, you can send a test command to the remote computer. 

Invoke-Command -ComputerName Servername -ScriptBlock {'Hello world'}

If errors out, it will display an ugly error message.  The error per se could be due to a completely different reason, maybe you typed in wrong server name, WinRM service is not running, issues with your credentials, firewall, trusts etc. 

Another, indirect method, is to run the Test-WSMan command to see whether WinRM service is running there or not.

Test-WSMan -ComputerName Servername 

This too doesn't guarantee to tell if issue is with PSRemoting setup.

May be some future version of PowerShell will add a command to check status of PSRemoting locally as well as remotely, in secure ways of course.  I am not too hopeful though because it has been enabled by default since Windows 2012, so Microsoft may not see a need for it or deem it a security risk.


Friday, September 22, 2023

Get Configuration Change History From the SQL Server Error Logs

Get Configuration Change History From the SQL Server Error Logs

There are several options if and when you need to see if any configuration change was made in SQL Server.

The simplest maybe to use the SSMS GUI, using its built-in (aka standard) reports:




 








The good thing about this standard report is that it reads from the default trace, which being the "default" setting, would have been already running, that means you don't need to setup and enable it. It is just there, unless of course it was purposely disabled/stopped.

The bad is that the default trace is of limited size, I think the size limit is 20 MB with up to 5 rollover files. You cannot change the size of default trace, you can however create your own trace, in which case better to look at setting up Extended Events instead.

You can also use the SQL Server Auditing and Extended Events features as well, which gives you lot more control on what to capture and to what size. The downside is that you need to set it up and it should be already running by the time there is a need to check if any configuration change was made.

SQL Server also logs anytime there is a configuration change, which you can then view and filter to find the configuration changes by searching for string "Configuration option". You can obviously use the SSMS to open and filter the log file, or even your favorite text editor. And better, you can programmatically read and filter using the sys.xp_readerrorlog:

https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-readerrorlog-transact-sql


Here are some examples:

Example 1: Search for entries containing Configuration option

USE master;
EXEC sys.xp_readerrorlog
	0, -- 0 for the current log file, 1 = archive file #1 etc
        1, -- 1 or NULL for SQL Server error log, 2 for the SQL Agent log
        N'Configuration option', -- Search/filter by this string
        NULL, -- Further refine the search/filter condition
        NULL, -- Start time
	NULL, -- End time
	N'desc'; -- Sorts the results by asc for ascending order or 
                 -- desc for descending order









Example 2: A second filter for the Start Time
USE master;
EXEC sys.xp_readerrorlog
	0, -- 0 for the current log file, 1 = archive file #1 etc
        1, -- 1 or NULL for SQL Server error log, 2 for the SQL Agent log
        N'Configuration option', -- Search/filter by this string
        NULL, -- Further refine the search/filter condition
        '2023-09-08 16:00:00', -- Start time
	NULL, -- End time
	N'desc'; -- Sorts the results by asc for ascending order or 
                 -- desc for descending order






Example 3: Further refine/search into the results
USE master;
EXEC sys.xp_readerrorlog
	0, -- 0 for the current log file, 1 = archive file #1 etc
        1, -- 1 or NULL for SQL Server error log, 2 for the SQL Agent log
        N'Configuration option', -- Search/filter by this string
        N'Optimize', -- Further refine the search/filter condition
        NULL, -- Start time
NULL, -- End time N'desc'; -- Sorts the results by asc for ascending order or
                 -- desc for descending order






So, what is the catch here? Every time SQL Server is restarted, it starts a new log file and renames the previous log file to errorlog.1, and the previous errrolog.1 gets renamed to errorlog.2 and so forth, up to 7 logs, which you can increase up to 99. Stored procedure sp_cycle_errorlog can be used to start a new log file in between the SQL Server restarts.  That means 1) The older configuration change entries would get moved to the so called archived error logs, which sys.xp_readerrorlog does allow to read from as well, and 2) Eventually the oldest archived error log files will get removed, unless of course you setup something to store the log entries somewhere.

Of course, you can also use sys.xp_readerrorlog for other purposes as well, for example to see if there have been any errors in last 24 hours.

USE master;
declare @start_time datetime = getdate() - 1
EXEC sys.xp_readerrorlog
    0, -- 0 for the current log file, 1 = archive file #1 etc
    1, -- 1 or NULL for SQL Server error log, 2 for the SQL Agent log
    N'Severity: 16', -- Search/filter by this string
    N'41145', --  Further refine the search/filter condition
    @start_time, -- Start time
    NULL, -- End time
    N'desc'; -- Sorts the results by asc for ascending order 
             -- or desc for descending order




See also


Emergency Access to SQL Server: A DBA's Guide to Regaining Control

Emergency Access to SQL Server: A DBA's Guide to Regaining Control

A word of caution:

I am a bit apprehensive here, so I would like to make this clear from the beginning: This is really intended for responsible and cautious DBAs who have a legitimate need to access SQL Server but there is no one around with DBA (i.e., sysadmin) role or any access at the instance level for that matter. Yes, there can still be loopholes in your SQL Servers that, if left open, can be leveraged by "ethical" as well as rogue actors alike. Here is a relatively old article discussing some of the ways SQL Server security might be compromised:



So, always stay vigilant!



How do you manage and troubleshoot a SQL Server if you don't any access to it at all? You can't.  Even if you have full administrative rights on the server or even in the entire IT network, you cannot connect to the SQL Server if you don't have valid credentials with sysadmin rights.

Unless...... it is a very very old SQL Server version, like SQL Server 7 or older. Back then SQL Server would automatically add the local administrators (BUILTIN\Administrators) as sysdba during the installation. You could go back and remove that access, but I don't think most people bothered.  But back then there was even a bigger security issue with SQL Server, blank password for the almighty sa user by default!!!!!
 
Fortunately those dark days are long gone, SQL Server has come a very long way since then and so have we, in our mindsets regarding the security. 

But that is not what I am here to talk about.

So back to the topic, this problem per se is not new and neither is the solution. Most long time DBAs would know the solution already, as they would have run into this issue at some point in their career. 

Let me just describe the solution briefly and get it out of the way as this article is not about that.

The solution requires that you are a member of the local administrators group on the computer where SQL Server instance is installed.  You log into the server, stop and then restart the SQL Server in a single user mode, that temporarily grants local administrators sysadmin permissions. You then connect to the SQL instance with Windows Authentication, and make yourself or any login you like a member of the sysadmin role. Then stop and restart the SQL service in normal mode.  VOILĂ€!
 
Below is a link to a how-to document on this very topic from Microsoft:
 
 
Recently, I got a new gig and had to solve this problem on more than 200 SQL Servers, over a single weekend! Given the short amount of time and a relatively large number of SQL Servers, I decided to write a PowerShell script that I can then call remotely on each SQL Server.  At first the PowerShell code was a relatively short one, without any validation or error checking. It basically restarts the SQL instance in single user mode, adds a AD domain group for DBAs to the sysadmin role then restarts back the SQL Instance and the SQL Agent services:

<#
TO RUN THIS SCRIPT:  
- LOGIN (USING EITHER REMOTE DESKTOP OR SSH) INTO THE TARGET COMPUTER  
- LAUNCH POWERSHELL AS ADMINISTRATOR  
#>
# INPUTS
$sql_instance_name = 'MSSQLSERVER' $login_to_be_granted_access = 'Contaso\Group-MSSQL-DBAs' # GET THE NAME FOR THE WINDOWS SERVICE AND THE SQL CONNECTION if($sql_instance_name -eq "MSSQLSERVER") { $service_name = 'MSSQLSERVER' $sql_server_instance = "." } else { $service_name = "MSSQL`$$sql_instance_name" $sql_server_instance = ".\$sql_instance_name" } $sql = "CREATE LOGIN [$login_to_be_granted_access] FROM WINDOWS GO ALTER SERVER ROLE sysadmin ADD MEMBER [$login_to_be_granted_access] GO " $check_permission = "IF EXISTS (SELECT * FROM sys.server_role_members WHERE member_principal_id = SUSER_ID('$login_to_be_granted_access') AND role_principal_id = SUSER_ID('sysadmin')) PRINT 'VERIFIED' ELSE RAISERROR('ERROR: Verification failed.', 16, 1) GO " # STOP/START SQL SERVER IN SINGLE USER MODE Stop-Service -Name $service_name -Force net start $service_name /f /m"SQLCMD" Start-Sleep 2 sqlcmd.exe -E -S $sql_server_instance -Q $sql sqlcmd.exe -E -S $sql_server_instance -Q $check_permission # Stop the service Stop-Service -Name $service_name -Force # RESTART SQL SERVICES IN NORMAL MODE $service = Start-Service -Name $service_name -PassThru Start-Service -Name $service.DependentServices[0].Name

Note: Restarting a Windows service requires administrator privileges, including for the process from where the service is being restarted from, which in this case is PowerShell and therefore it needs to be launched as “Run As Administrator”:























Later, I added more code for validation, error checking, and an option to create a SQL Login instead of a Windows Login. Most importantly, I added the ability to run this script remotely. As a result, the script is now longer than it perhaps should be.


##### CAUTION: THE SCRIPT WILL STOP AND RESTART YOUR SQL SERVER INSTANCE!!!!!!!!! 
<#
THIS SCRIPT IS INTENDED TO GET ACCESS TO SQL SERVER ONLY IF 
YOU DON'T HAVE SYSADMIN PERMISSION. USE ONLY IN EMERGENCY.

#>

<#
.NOTATION
This script is intentionally lengthy for several important reasons:

- It must handle multiple complex steps safely to grant sysadmin access to SQL Server
- It needs to validate environment prerequisites such as elevated rights and service state
- The script carefully stops, starts, and manages dependencies of SQL Server services
- Confirmation prompts and detailed error handling are included to prevent unintended disruptions
- To avoid automation mistakes, explicit validations and user prompts are mandatory
- The overall complexity reflects the sensitive operation of forcibly gaining sysadmin access

Please read and understand each section of the script carefully before using it,
and always run this script in a controlled environment with proper permissions.

#>

<#
REQUIREMENTS:
1. Local Administrator rights on the server
2. Run locally or via Invoke-Command with PSRemoting enabled (default on Windows Server 2012+)
3. Elevated PowerShell session (Run as Administrator)

PARAMETERS:
- $login_to_be_granted_access (string, required): Windows or SQL login to grant sysadmin access
- $sql_instance_name (string, optional): SQL instance name (default instance if omitted)
- $confirm (bool, optional): Prompt for confirmation before stopping SQL service. Default: $true
- $sql_login_password (string, optional): SQL login password required if SQL login

#>

<# 
Examples:

Example 1: Running the Script Locally

- Save the script on your local device, for example as C:\Scripts\Gain-SqlSysadminAccess.ps1
- Open PowerShell with Administrative privileges (Run as Administrator).
- Navigate to the folder containing your script, for example: 
  cd C:\Scripts

- Run the script with required parameters. For example, to grant sysadmin access to a 
  Windows login named "DOMAIN\User1" on default instance with confirmation prompt:

.\Gain-SqlSysadminAccess.ps1 -login_to_be_granted_access "DOMAIN\User1" -confirm $false


Example 2: Running the Script Remotely Using Invoke-Command

- From your local machine with PowerShell launched as Administrator, 
  run the script on a remote computer (e.g., RemoteServer01). 
  Make sure PSRemoting is enabled on the remote server.

- Use the following command to invoke the script remotely:

# Run the local script on a remote computer, passing parameters
Invoke-Command -ComputerName RemoteServer01 `
    -FilePath "C:\Scripts\Gain-SqlSysadminAccess.ps1" `
    -ArgumentList "DOMAIN\User1", "SQL2022AG01", $false

- Replace parameters accordingly for your target environment


#>

param (
    [string] $login_to_be_granted_access = 'sqladmin',
    [string] $sql_instance_name = 'SQL2022AG01',
    [bool] $confirm = $true,
    [string] $sql_login_password = 'WA1!!1P7JRjN7F4eibEES&IxU%Elgw6b#'
)

# Set default preferences
$ErrorActionPreference = 'Stop'
$WarningPreference = 'Continue'
$InformationPreference = 'Continue'

# Assume default instance if sql_instance_name not specified
if (-not $sql_instance_name) { $sql_instance_name = 'MSSQLSERVER' }
if ($null -eq $confirm) { $confirm = $true }

Write-Information "Computer Name: $env:COMPUTERNAME"
Write-Information "SQL Instance Name: $sql_instance_name`n"

# Confirm prompt if required
if ($confirm) {
    $valid_responses = 'Yes', 'yes', 'No', 'no'
    do {
        Write-Warning "##### CAUTION: THE SCRIPT WILL STOP AND RESTART YOUR SQL SERVER INSTANCE!!!!!!!!!"
        $response = Read-Host "Are you sure you want to continue (Yes/No)?"
        if (-not $valid_responses.Contains($response)) {
            Write-Host "Please enter Yes or No"
        }
    } until ($valid_responses.Contains($response))

    if ($response -in @('No', 'no')) { return }
}
else {
    Write-Warning "Confirmation prompts are disabled."
    Write-Information ""
}

# Validate mandatory parameters
if (-not $sql_instance_name -or -not $login_to_be_granted_access) {
    throw "Error: Both `\$sql_instance_name` and `\$login_to_be_granted_access` are required."
}
if (-not $login_to_be_granted_access.Contains('\') -and -not $sql_login_password) {
    throw "A password must be provided for SQL Login."
}

# Check for elevated privileges
$isAdmin = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).Owner -eq 'S-1-5-32-544'
if (-not $isAdmin) {
    throw "Error: Powershell must be launched in elevated privileges mode (Run as Administrator)."
}

# Determine service and SQL Server instance names
if ($sql_instance_name -eq 'MSSQLSERVER') {
    $service_name = 'MSSQLSERVER'
    $sql_server_instance = '.'
}
else {
    $service_name = "MSSQL`$$sql_instance_name"
    $sql_server_instance = ".\$sql_instance_name"
}

Write-Information "SQL Server Instance: $sql_server_instance"
Write-Information "Service Name: $service_name`n"

# Get SQL service and dependent services
$sql_service = Get-Service -Name $service_name -ErrorAction Stop
$dependent_services = $sql_service.DependentServices

if (-not $sql_service) {
    throw "Error: SQL instance '$sql_instance_name' or service '$service_name' not found."
}

Write-Information "Service Status: $($sql_service.Status)"
Write-Information "Service Startup Type: $($sql_service.StartType)`n"

# Re-enable if disabled
if ($sql_service.StartType -eq 'Disabled') {
    Write-Warning "SQL instance '$sql_instance_name' is currently disabled."

    if ($confirm) { Set-Service -Name $service_name -StartupType Manual -Confirm }
    else { Set-Service -Name $service_name -StartupType Manual }

    $sql_service.Refresh()
    if ($sql_service.StartType -eq 'Disabled') {
        throw "Error: Cannot continue while SQL instance is Disabled."
    }
}

# Stop the service if running
if ($sql_service.Status -eq 'Running') {
    Write-Warning "Stopping service: $service_name and its dependent services..."

    if ($confirm) { Stop-Service -InputObject $sql_service -Confirm -Force }
    else { Stop-Service -InputObject $sql_service -Force }

    Start-Sleep -Seconds 1

    $sql_service.Refresh()
    if ($sql_service.Status -ne 'Stopped') {
        throw "Error: SQL instance service '$service_name' did not stop as expected."
    }
}

# Start the service in single-user mode if appropriate
$sql_service.Refresh()
if ($sql_service.Status -ne 'Running' -and $sql_service.StartType -in @('Manual', 'Automatic')) {
    Write-Warning "Starting SQL Server service in single user mode..."
    Write-Information ""

    net start $service_name /f /m"SQLCMD" | Out-Null
    Start-Sleep -Seconds 1

    $sql_service.Refresh()
    if ($sql_service.Status -eq 'Running') {

        if ($login_to_be_granted_access.Contains('\')) {
            $sql = @"
CREATE LOGIN [$login_to_be_granted_access] FROM WINDOWS;
GO
ALTER SERVER ROLE sysadmin ADD MEMBER [$login_to_be_granted_access];
GO
SELECT @@ERROR AS [ErrMsg];
GO
"@
        }
        else {
            $sql = @"
CREATE LOGIN [$login_to_be_granted_access] WITH PASSWORD=N'$sql_login_password', DEFAULT_DATABASE=[master], CHECK_EXPIRATION=OFF, CHECK_POLICY=OFF;
ALTER SERVER ROLE sysadmin ADD MEMBER [$login_to_be_granted_access];
SELECT @@ERROR AS [ErrMsg];
"@
        }

        Write-Information "Adding login '$login_to_be_granted_access' to SYSADMIN role..."
        Write-Information $sql

        sqlcmd.exe -E -S $sql_server_instance -Q $sql

        Write-Information ""

        $check_permission = @"
IF EXISTS (
    SELECT * FROM sys.server_role_members
    WHERE member_principal_id = SUSER_ID('$login_to_be_granted_access')
      AND role_principal_id = SUSER_ID('sysadmin')
)
    PRINT '****** VERIFICATION SUCCEEDED ****************'
ELSE
    RAISERROR('ERROR: Verification failed.', 16, 1);
GO
"@

        Write-Information "Verifying sysadmin permissions..."
        Write-Information $check_permission

        sqlcmd.exe -E -S $sql_server_instance -Q $check_permission

        Write-Information ""
        Write-Information "Restarting SQL instance in normal mode..."

        net stop $service_name | Out-Null
        net start $service_name | Out-Null

        Write-Information ""
        Write-Information "Restart dependent services if they were running previously"
        Write-Information ""
        $dependent_services | Format-Table -Property DisplayName, Status, StartType
        Write-Information ""

        foreach ($dependent_service in $dependent_services) {

            $dependent_service_name = $dependent_service.Name
            if ($dependent_service.Status -eq 'Running') {
                if ((Get-Service -Name $dependent_service_name).Status -ne 'Running') {
                    Write-Information "Starting dependent service: $dependent_service_name"
                    $dependent_service.Start()
                }
            }
        }
    }
    else {
        throw "Error: SQL instance did not start as expected."
    }
}


Now what? How do you use it then? If it is just one or two SQL Servers, you can simply copy/paste it into an elevated PowerShell session, and edit the param block with the appropriate values.

param (
  [string] $login_to_be_granted_access = 'sqladmin',
  [string] $sql_instance_name = 'SQL2022AG01',  
  [Boolean] $confirm = $true,
  [string] $sql_login_password = 'WA1!!1P7JRjN7F4eibEES&IxU%Elgw6b#'
)

Note:  You can give a Windows login for the $login_to_be_granted_access parameter, as long as it contains a "\" in it, the script will know it is a Windows login.



Then click the green Run Script button or hit F5 key:































It will display a warning and ask if you want to continue:









It will also ask to confirm again before stopping the SQL Service.

You can disable the confirmation prompts by setting $confirm = $false, which is what I would do when running this in non-interactive mode, especially in a batch mode when running it against multiple SQL Servers at the same time. In the following example, the script is saved in a file "Gain-SqlSysadminAccess.ps1, then using the Invoke-Command, I can run it on a remote server (highlighted):



# Add integrated/windows authenticated login or group 
$script_file_path = "Gain-SqlSysadminAccess.ps1"
Invoke-Command -ComputerName SQLServerVM01 -FilePath $script_file_path `
               -ArgumentList 'Contaso\Group-MSSQL-DBAs', 'SQL2022AG01', $false




# Add a SQL Server authenticated login 
$script_file_path = "Gain-SqlSysadminAccess.ps1"
Invoke-Command -ComputerName SQLServerVM01 -FilePath $script_file_path `
               -ArgumentList 'sqladmin', 'SQL2022AG01', $false, 'WA1!!1P7JRjN7F4eibEES'
































To run the script on multiple SQL Servers, you can save the list of SQL servers, instance names and login name in a CSV file. For integrated/Windows logins, the LoginName needs to be in "ServerName\UserName" or "Domain\UserName" format and must contain a "\" as that is what my script is using to dynamically determine whether is is Windows login or a SQL login. For example:
















Then use the following PowerShell code to execute the script against all servers listed in the CSV file:

$script_file_path = "Gain-SqlSysadminAccess.ps1"
$csv_data = Import-Csv -Path 'C:\Users\dummy\Documents\myServers.csv'

foreach($csv_row in $csv_data)
{
    Invoke-Command -ComputerName $csv_row.ServerName -FilePath $script_file_path `
                   -ArgumentList $csv_row.LoginName, $csv_row.SQLInstance, `
                                 $false, $csv_row.Password
}


I hope you find this useful in times of need. If you see or come across a bug in this PowerShell code, please let me know. 















A Study in SQL Server Ad hoc Query Plans

A Study in SQL Server Ad hoc Query Plans

Finding about 30 GB in the plan cache will get a DBA’s attention. That happened during a 2023 investigation, on a SQL Server instance with more than 2 TB of memory available. Thirty gigabytes might sound less alarming on a server that large, but my next question was still the same: how much of that memory was earning its keep?

Plan caching is useful because reusing a suitable execution plan avoids repeating compilation work, but retaining plans that never get reused consumes memory without delivering that reuse benefit. (Microsoft’s query processing guide) The goal is not simply a smaller cache. I want a cache that supports the workload efficiently, without paying unnecessarily in either memory or compilation work.

In this article, I’ll walk through the original findings, explain what ad hoc plans and compiled plan stubs actually tell us, and show how I would investigate the same situation today. I’ll also explain why I recommend enabling optimize for ad hoc workloads when substantial memory is tied up in full single-use ad hoc plans.

The environment that started the investigation

This was a large analytical and reporting environment, not a small transactional application. The investigation involved SQL Server 2019 CU18, a database of approximately 60 TB, and more than 2 TB of memory and 112 CPU cores available to the instance.

Application changes were not a practical short-term option, and my assignment was to investigate and recommend changes, not implement them. That distinction matters: this is a diagnostic case study, not a before-and-after account of a production change.

The original cache-memory snapshot looked like this:

Cache object type Reported memory, MB
All displayed entries 31,257
Adhoc 29,594
Prepared 882
Proc 506
View 269
Rule 2

These are historical, rounded values recorded during that investigation, not results produced by the revised queries below. The Adhoc entries accounted for approximately 95% of the reported cache memory.

That is a useful finding, but it does not mean 95% of the workload’s executions, CPU consumption, or elapsed time came from ad hoc queries; the measurement is cache memory. I would investigate the concentration of memory before deciding whether it represented waste, pressure, or a perfectly reasonable working cache.

And no, my preferred fix is not to convince management that every SQL Server needs 2 TB of RAM. I would rather start by understanding what we are keeping in memory and why.

Ad hoc does not mean single-use

Before looking at more numbers, let’s clear up an easy misunderstanding: Adhoc describes a cached object’s type, not how often its plan has been used. An ad hoc plan can be reused, and the DMV exposes object type and lookup count as separate attributes. (Microsoft’s cached-plan DMV documentation)

Two columns are especially useful here:

  • objtype: Identifies categories such as Adhoc, Prepared, and Proc. 
  • cacheobjtype: Identifies the cached representation, including Compiled Plan and Compiled Plan Stub. 

Also, the tool submitting a query does not decide its caching behavior by itself: parameterized calls, stored procedures, automatic parameterization, and the relevant execution context all affect plan reuse. “It came from SSMS” is therefore not the diagnosis I would stop at.

The question I care about is more specific: are we retaining a substantial amount of full ad hoc plan memory for batches that rarely, or never, benefit from reuse?

Start with a read-only cache inventory

I would start directly with sys.dm_exec_cached_plans, separating object types and distinguishing full plans from stubs. Its size_in_bytes and usecounts columns let us inspect memory consumption and cache lookups without first joining to statement-level statistics.

For these server-wide DMV examples, SQL Server 2019 requires VIEW SERVER STATE; SQL Server 2022 and later require VIEW SERVER PERFORMANCE STATE. Those permissions are separate from permission to change server configuration. 

-- Read-only: inventory currently cached entries.
;WITH CacheGroups AS
(
    SELECT
        objtype,
        cacheobjtype,
        COUNT_BIG(*) AS cache_entry_count,
        SUM(CONVERT(bigint, size_in_bytes)) AS total_bytes,
        SUM(CASE WHEN usecounts = 1
                 THEN CONVERT(bigint, size_in_bytes)
                 ELSE CONVERT(bigint, 0) END) AS single_lookup_bytes
    FROM sys.dm_exec_cached_plans
    GROUP BY objtype, cacheobjtype
)
SELECT
    objtype,
    cacheobjtype,
    cache_entry_count,
    CAST(total_bytes / 1048576.0 AS decimal(19, 2)) AS size_mb,
    CAST(100.0 * total_bytes
         / NULLIF(SUM(total_bytes) OVER (), 0)
         AS decimal(9, 2)) AS pct_of_reported_cache_bytes,
    CAST(single_lookup_bytes / 1048576.0 AS decimal(19, 2))
        AS single_lookup_mb
FROM CacheGroups
ORDER BY total_bytes DESC;
GO

The byte totals are aggregated before conversion to MB, and the percentage uses the total bytes reported by this query as its denominator. It is not a percentage of all SQL Server memory.

Start with the Adhoc / Compiled Plan row, then compare it with Adhoc / Compiled Plan Stub. Those are different cache representations, so a large number of small stubs is not the same finding as a large amount of memory occupied by full plans. 

Be careful with “used once”

I deliberately called the last column single_lookup_mb, not executed_once_mb: usecounts records cache-object lookups and is not a universally reliable execution count. Microsoft documents exceptions, including parameterized plan matches and Showplan-related behavior. 

I use that bucket as a practical starting point for investigation, not a complete workload history. Take samples across representative reporting or ETL cycles and review the actual batches before drawing a conclusion about their reuse.

Here is a read-only query to inspect the largest full ad hoc plans with one recorded cache lookup:

SELECT TOP (50)
    cp.plan_handle,
    cp.objtype,
    cp.cacheobjtype,
    cp.usecounts AS cache_lookup_count,
    cp.size_in_bytes,
    st.text AS batch_text
FROM sys.dm_exec_cached_plans AS cp
OUTER APPLY sys.dm_exec_sql_text(cp.plan_handle) AS st
WHERE cp.objtype = N'Adhoc'
  AND cp.cacheobjtype = N'Compiled Plan'
  AND cp.usecounts = 1
ORDER BY cp.size_in_bytes DESC, cp.plan_handle;
GO

Notice that the full-plan filter uses cacheobjtype, not objtype; Compiled Plan Stub is a value of the former column. Also, treat the returned batch text as potentially sensitive when sharing diagnostic results.

Why not add up execution counts?

sys.dm_exec_query_stats contains statement-level statistics, so summing execution_count for a plan handle does not reliably tell us how many times the containing batch was submitted. Its rows describe statements within cached plans, and its statistics reflect completed executions. 

For example, imagine a batch containing three statements, each executed once. Adding their execution counts gives three, even though the batch was submitted only once.

An earlier version of this article included a result of approximately 28 GB selected by low-activity and age conditions. On review, that query aggregated statement execution counts, and its stub-exclusion predicate checked the wrong column. I have replaced that approach with the cache-entry measurements above; the earlier result should not be read as a clean measurement of single-use full plans or as a savings forecast.

For a cache-memory inventory, keep the measurement at the cache-entry level. Bring in statement statistics separately when the question is about statement activity.

What optimize for ad hoc workloads actually changes

With optimize for ad hoc workloads enabled, SQL Server still compiles and executes an eligible ad hoc batch on its first invocation, but retains a small compiled plan stub instead of the full compiled plan. The setting reduces first-use cache memory; it does not eliminate that first compilation. (Microsoft’s configuration guidance)

The sequence is:

  1. First invocation: SQL Server compiles and executes the batch, then caches a stub rather than the full plan.
  2. Next matching invocation, while the stub remains cached: SQL Server compiles the batch again and replaces the stub with a full compiled plan.
  3. Later matching invocations: The full cached plan is available for reuse, subject to the normal rules for matching, invalidation, and eviction. 

The stub lets SQL Server recognize a previously compiled batch, but it does not contain a full execution plan that you can retrieve as graphical or XML plan output. That is worth remembering when troubleshooting a query whose only cached representation is a stub.

This is a trade-off, not free performance: a batch that returns for a second matching invocation needs another compilation before its full plan is retained. That is why I measure compilation activity alongside memory rather than declaring victory as soon as the cache gets smaller.

My recommendation

When substantial memory is tied up in full single-use ad hoc plans, I recommend enabling optimize for ad hoc workloads through the normal change-control process. It is designed specifically to reduce the cache-memory footprint of ad hoc batches that are not reused.

I regard this as a practical best practice for that workload pattern, not merely an obscure option to keep in the back pocket. However, a high Adhoc percentage alone would not persuade me; I would establish how much memory full low-reuse plans occupy.

Microsoft’s explicit recommendation discusses OLTP servers, whereas this case involved an analytical workload. My recommendation for this environment is to apply the same memory-saving mechanism, then validate the benefit against its reporting and ETL patterns.

I would check:

  • Memory: Are full low-reuse ad hoc plans occupying less memory over a representative workload cycle?
  • Compilation: Has compilation activity or compilation-related CPU become more expensive?
  • Workload performance: Are important reports and ETL jobs running at least as well as before?
  • Troubleshooting: Can the team collect the execution-plan evidence it needs when only a stub remains cached?

I would not promise a specific number of gigabytes saved before measuring the result. A configuration setting deserves a recommendation based on evidence and a follow-up based on evidence.

Making an approved configuration change

The following is an instance-wide configuration change, not a diagnostic query; use it only after approval on the intended instance. A disposable database on a shared instance does not isolate a server configuration change.

Record the existing configured and running values first. Changing configuration through sp_configure and RECONFIGURE requires appropriate server permissions, including ALTER SETTINGS. 

-- Read-only: save this output in the change record.
SELECT
    name,
    value AS configured_value,
    value_in_use AS running_value
FROM sys.configurations
WHERE name IN
(
    N'show advanced options',
    N'optimize for ad hoc workloads'
);
GO

For an approved change, this block enables the option and restores show advanced options to its previous running state. Use it only when no other configuration change is in progress; review any pre-existing differences between configured and running values first.

-- CHANGE SCRIPT: approved instance only.
DECLARE @OriginalShowAdvanced int;

SELECT @OriginalShowAdvanced = CONVERT(int, value_in_use)
FROM sys.configurations
WHERE name = N'show advanced options';

IF @OriginalShowAdvanced = 0
BEGIN
    EXEC sys.sp_configure N'show advanced options', 1;
    RECONFIGURE;
END;

EXEC sys.sp_configure N'optimize for ad hoc workloads', 1;
RECONFIGURE;

IF @OriginalShowAdvanced = 0
BEGIN
    EXEC sys.sp_configure N'show advanced options', 0;
    RECONFIGURE;
END;

SELECT
    name,
    value AS configured_value,
    value_in_use AS running_value
FROM sys.configurations
WHERE name IN
(
    N'show advanced options',
    N'optimize for ad hoc workloads'
);
GO

If the script stops with an error, recheck both settings rather than assuming the final restoration ran. For rollback, use the recorded original setting through the same approved configuration process.

Enabling the option affects newly cached plans; it does not convert existing full plans into stubs. I would normally allow the cache to turn over with the workload rather than clear it simply to make a before-and-after screenshot look more dramatic.

A small lab makes the behavior easier to see

During my testing for the 2023 investigation, I submitted comparable queries as an ad hoc batch, a parameterized sp_executesql call, and a stored procedure. I observed a 456-byte ad hoc stub alongside 155,648-byte full plans for the prepared and procedure examples; those are observations from that particular test, not fixed sizes to expect elsewhere.

To focus on the stub lifecycle, use an isolated test instance with the setting enabled. Record its original configuration before changing anything, and restore it after the exercise if needed.

Run the following as its own batch, once. If you have already used this marker in your lab, change its suffix before starting, then keep the complete batch text unchanged throughout the test.

/* SQLPAL_AdHocStub_Demo_A01 */
SELECT name
FROM sys.databases
WHERE name = N'tempdb';
GO

Now inspect the cache separately:

-- The split marker avoids matching this inspection batch itself.
SELECT
    cp.plan_handle,
    cp.objtype,
    cp.cacheobjtype,
    cp.usecounts AS cache_lookup_count,
    cp.size_in_bytes,
    st.text AS batch_text
FROM sys.dm_exec_cached_plans AS cp
OUTER APPLY sys.dm_exec_sql_text(cp.plan_handle) AS st
WHERE st.text LIKE N'%SQLPAL_' + N'AdHocStub_Demo_A01%'
ORDER BY cp.objtype, cp.cacheobjtype;
GO

For an eligible first-use ad hoc entry, look for objtype = Adhoc and cacheobjtype = Compiled Plan Stub; then run the exact original test batch again and inspect the cache a second time. If the matching stub was still present, the documented next step is a full compiled plan. 

If your output differs, check the running setting, whether the batch was already cached, and whether parameterization or execution context changed its representation or matching behavior. Automatic parameterization can introduce reusable prepared plans, so not every literal-containing query is a clean demonstration of the same cache path.

The point is to observe the cache object, not force a particular byte count or treat usecounts as a complete execution history. There is no cache-clearing command in this exercise.

What I would not do

Turn this into a scheduled cache-cleaning job

I would not routinely evict plans just because they are several hours old or have low observed activity. DBCC FREEPROCCACHE removes cached plans, and subsequent requests can incur compilation costs to replace them. 

Consider a daily report: its plan may look idle most of the day and still be useful at the next scheduled execution, provided it remains valid and cached. Frequency alone does not determine whether a cached plan can be reused.

Targeted eviction can have a place in approved troubleshooting, but I would not present it as routine housekeeping or an equivalent substitute for first-use stubs. My default is to diagnose the cause, not schedule the symptom to disappear.

Treat the setting as a substitute for parameterization

optimize for ad hoc workloads changes what is retained after first use; parameterization can allow suitable executions with different parameter values to share a plan. They address different parts of the problem. 

Where application changes are possible, I would also review parameterized calls, consistent parameter definitions, and whether the shared plan performs well across representative values. Where they are not immediately possible, I would still pursue a justified server-side improvement rather than wait indefinitely.

The takeaway

The historical snapshot told me where the cache memory was going, but it did not prove how often every batch executed or how much memory a configuration change would save. My role in that investigation was to recommend changes, so I do not have a measured production before-and-after result to claim here.

My recommendation remains straightforward: enable optimize for ad hoc workloads when substantial memory is being consumed by full single-use ad hoc plans, and validate the outcome against the actual workload. The setting’s purpose is to reduce that first-use memory footprint, not to make every query faster or eliminate compilation. 

A smaller cache is not the finish line, and a larger cache is not automatically a problem. I want useful reuse, sensible memory consumption, and reports that finish when the business needs them.

For the broader diagnostic approach, continue with Measuring and Improving SQL Server Query Plan Cache Efficiency. Use this case study to understand the ad hoc memory problem, then use the companion article to put that finding into the wider performance picture.