<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Chandler Gray</title><description>chandlergray.com is a blog about SQL Server, PowerShell, Python, Terraform, Docker, and anything else I find interesting.</description><link>https://chandlergray.com/</link><language>en-us</language><managingEditor>contact@chandlergray.com (Chandler Gray)</managingEditor><webMaster>contact@chandlergray.com (Chandler Gray)</webMaster><copyright>© 2026 Chandler Gray. Content licensed CC BY 4.0.</copyright><item><title>How to Deploy a SQL Server Availability Group for High Availability</title><link>https://chandlergray.com/blog/how-to-deploy-a-sql-server-availability-group-for-high-availability/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-deploy-a-sql-server-availability-group-for-high-availability/</guid><description>A walkthrough for building a two-node SQL Server 2025 Availability Group with automatic failover, from standing up the AD forest to proving failover works.</description><pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I&apos;ve built dozens of SQL Server availability groups at work but had never built one for my homelab. The main reason is that doing it right meant setting up an Active Directory domain, which I&apos;d never done before. That&apos;s done now, so I was ready to get started.&lt;/p&gt;
&lt;p&gt;This post only covers standard AGs. I&apos;m not covering Contained AGs here, but I plan to write one for that separately.&lt;/p&gt;
&lt;p&gt;Some details about my homelab first:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I run a single Proxmox node, managed largely through Terraform.&lt;/li&gt;
&lt;li&gt;Storage is just a standard ZFS model.&lt;/li&gt;
&lt;li&gt;I reserved the IP addresses for the servers, LB, and so on through my Unifi gateway.&lt;/li&gt;
&lt;li&gt;All three servers in the lab (two SQL Servers and one AD) run unlicensed Windows Server 2025.&lt;/li&gt;
&lt;li&gt;My two SQL Servers are both running SQL Server 2025 Enterprise Developer Edition.&lt;/li&gt;
&lt;li&gt;Since everything is on one host, HA won&apos;t help me if the storage or the host dies. It&apos;s a homelab and nothing critical will be stored on it, so I&apos;m accepting that risk.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Windows Server Active Directory.&lt;/li&gt;
&lt;li&gt;Two fresh SQL Server 2025 instances.&lt;/li&gt;
&lt;li&gt;Ability to reserve at least five IP addresses on your network.&lt;/li&gt;
&lt;li&gt;Some way to configure DNS, I use AdGuard.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;There are a few different ways to configure your AG, but what I&apos;m building is &lt;strong&gt;two SQL Server 2025 nodes&lt;/strong&gt; in a &lt;strong&gt;synchronous-commit availability group&lt;/strong&gt; with &lt;strong&gt;automatic failover&lt;/strong&gt;. These will have a listener, and a quorum held by a file-share witness on the domain controller.&lt;/p&gt;
&lt;p&gt;My VLAN is &lt;code&gt;192.168.25.0/26&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If you haven&apos;t touched networking outside of what SQL Server makes you deal with, here&apos;s what that means. A VLAN is just a logically isolated network segment, in my case one carved out on my router for these five machines. The &lt;code&gt;/26&lt;/code&gt; is the subnet mask written in CIDR notation, and it tells you how many addresses that segment actually has. A &lt;code&gt;/26&lt;/code&gt; gives you 64 addresses (192.168.25.0 through 192.168.25.63), which is plenty for a handful of lab servers. You&apos;ll see this same &lt;code&gt;/26&lt;/code&gt; show up again later when the AG listener gets created, since SQL Server wants that mask spelled out for the IP it binds to.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Hostname&lt;/th&gt;
&lt;th&gt;IP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DC · DNS · Witness&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DC-01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;192.168.25.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AG Primary&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SQL25-WIN25-01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;192.168.25.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AG Secondary&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SQL25-WIN25-02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;192.168.25.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cluster name object (CNO)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SQL25-CLU-01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;192.168.25.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AG Listener&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SQL25-AG-01L&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;192.168.25.16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two of these rows aren&apos;t physical machines. The CNO is a computer object the cluster creates in AD to represent the cluster itself, and the listener is another computer object that represents the AG&apos;s single connection point. Both get their own IP and DNS name, but neither one is a server you log into. More on both below.&lt;/p&gt;
&lt;p&gt;The domain is &lt;code&gt;lab.local&lt;/code&gt; (NetBIOS &lt;code&gt;LAB&lt;/code&gt;). If you&apos;re not used to AD terminology, the domain name (&lt;code&gt;lab.local&lt;/code&gt;) is what you&apos;ll use in full commands and connection strings, while the NetBIOS name (&lt;code&gt;LAB&lt;/code&gt;) is the short legacy form Windows still uses in places like &lt;code&gt;LAB\svc-sql-engine&lt;/code&gt;, meaning the &lt;code&gt;svc-sql-engine&lt;/code&gt; account inside the &lt;code&gt;LAB&lt;/code&gt; domain.&lt;/p&gt;
&lt;p&gt;Every node gets its IP from a DHCP reservation on the Unifi gateway rather than an in-guest static. A reservation just means the router always hands that machine the same address based on its network card&apos;s MAC address, so it behaves like a static IP without you having to configure it inside Windows. I find reservations easier to keep track of in one place, but a normal static IP set inside the guest works exactly the same for everything in this post.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/ag-topology.BDmDYa_l_5xHII.webp&quot; alt=&quot;Topology diagram of the lab availability group inside VLAN 192.168.25.0/26. Three solid boxes are physical machines: DC-01 at 192.168.25.12 serving DNS and the file-share witness, SQL25-WIN25-01 at 192.168.25.13 as the primary replica, and SQL25-WIN25-02 at 192.168.25.14 as the secondary replica, with a synchronous-commit replication link running between the two SQL nodes. Two dashed boxes are Active Directory objects rather than servers you log into: the cluster name object SQL25-CLU-01 at 192.168.25.15 and the availability group listener SQL25-AG-01L at 192.168.25.16.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Phase 1 - Deploy and Baseline&lt;/h2&gt;
&lt;p&gt;First get a SQL baseline captured (run on both instances):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT @@VERSION, SERVERPROPERTY(&apos;Edition&apos;), SERVERPROPERTY(&apos;EditionID&apos;), SERVERPROPERTY(&apos;Collation&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, check that both instances are on the same version of SQL Server. Enterprise Edition is ideal. Standard Edition only supports &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/database-engine/availability-groups/windows/basic-availability-groups-always-on-availability-groups&quot;&gt;basic availability groups&lt;/a&gt;, which are limited to two replicas and one database, but that&apos;s enough for the single test database in this lab.&lt;/p&gt;
&lt;h2&gt;Phase 2 - Active Directory and Domain Join&lt;/h2&gt;
&lt;p&gt;This phase is where I spent most of my time, mostly because I&apos;d never stood up a fresh forest before. I&apos;ve laid it out below in the order that worked, with some of my notes on stuff that helped me get unstuck.&lt;/p&gt;
&lt;h3&gt;Stand up the domain controller&lt;/h3&gt;
&lt;p&gt;First, rename the host and reboot, since a DC&apos;s name can&apos;t easily change after promotion.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Rename-Computer -NewName DC-01 -Force
Restart-Computer -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, point the DC&apos;s DNS at itself. This is a real &quot;wait, why&quot; moment the first time you hit it: once a machine becomes a domain controller, it also becomes a DNS server, and AD leans on DNS constantly to find things like other domain controllers and services. So the DC needs to ask itself for answers rather than pointing out to your router or ISP like a normal client would. I keep DHCP enabled on the server (the reservation on the Unifi gateway pins the IP anyway), so the only thing that needs to change here is where it resolves names.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$if = (Get-NetAdapter | Where-Object Status -eq &apos;Up&apos; | Select-Object -First 1).Name
Set-DnsClientServerAddress -InterfaceAlias $if -ServerAddresses 127.0.0.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Install the AD DS role, then create the forest. &lt;code&gt;Install-ADDSForest&lt;/code&gt; is what actually promotes the box to a domain controller, installs DNS, and creates the domain everything else will join. Setting &lt;code&gt;-DomainMode&lt;/code&gt; and &lt;code&gt;-ForestMode&lt;/code&gt; to &lt;code&gt;Win2025&lt;/code&gt; is fine here since every machine in the lab is already on Server 2025.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Install-WindowsFeature AD-Domain-Services -IncludeManagementTools

Install-ADDSForest `
    -DomainName lab.local -DomainNetbiosName LAB `
    -DomainMode Win2025 -ForestMode Win2025 -InstallDns `
    -SafeModeAdministratorPassword (ConvertTo-SecureString &apos;YourStrongP@ssword!&apos; -AsPlainText -Force) `
    -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The machine reboots on its own after this. Log back in as &lt;code&gt;LAB\Administrator&lt;/code&gt; and confirm the forest is actually healthy before moving on.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-ADForest | Format-List Name,ForestMode,DomainNamingMaster
Get-Service ADWS,NTDS,DNS,Netlogon | Format-Table Name,Status
Resolve-DnsName lab.local
dcdiag /test:Advertising /test:Services /test:MachineAccount | Select-String &apos;passed|failed&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;DNS forwarding, reverse zone, and time sync&lt;/h3&gt;
&lt;p&gt;The DC now answers authoritatively for &lt;code&gt;lab.local&lt;/code&gt;, meaning it&apos;s the one source of truth for anything under that domain name, but it still needs somewhere to send everything else, like a request to resolve google.com. That&apos;s what a forwarder is: a second DNS server the DC hands off to whenever it gets asked about a name it doesn&apos;t own. I point mine at AdGuard, which is doing my ad blocking and upstream resolution for the rest of the network anyway.&lt;/p&gt;
&lt;p&gt;The reverse zone is DNS running backwards. Normal DNS answers &quot;what&apos;s the IP for this name,&quot; and a reverse zone answers &quot;what&apos;s the name for this IP.&quot; I set one up for the lab subnet alongside the forward zone.&lt;/p&gt;
&lt;p&gt;Last, since this DC is going to be the time source for the rest of the domain, I pointed it at a real NTP pool instead of letting it drift on its own. This matters more than it sounds like it should: Kerberos, the authentication protocol AD and SQL Server both rely on here, rejects requests if the clocks on either side have drifted more than about five minutes apart. While researching, I found that clock drift is a surprisingly common cause of logins that fail for no obvious reason.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set-DnsServerForwarder -IPAddress 192.168.25.53
if (-not (Get-DnsServerZone -Name &apos;0.25.168.192.in-addr.arpa&apos; -EA SilentlyContinue)) {
    Add-DnsServerPrimaryZone -NetworkId &apos;192.168.25.0/26&apos; -ReplicationScope Forest
}
w32tm /config /manualpeerlist:&apos;pool.ntp.org&apos; /syncfromflags:manual /reliable:yes /update
Restart-Service w32time
Resolve-DnsName google.com | Select-Object -First 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With that done, I went into AdGuard (192.168.25.53) under Settings &amp;gt; DNS &amp;gt; Upstream servers and added a conditional forward, which is a rule that says &quot;if the request is for this specific domain, send it here instead of the normal upstream,&quot; so AdGuard sends anything for my domain back to the DC:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[/lab.local/]192.168.25.12
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Organizational units and service accounts&lt;/h3&gt;
&lt;p&gt;I keep it simple with two OUs, one for the SQL Servers and one for service accounts.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$base = (Get-ADDomain).DistinguishedName
foreach ($ou in &apos;SQL Servers&apos;,&apos;Service Accounts&apos;) {
    if (-not (Get-ADOrganizationalUnit -Filter &quot;Name -eq &apos;$ou&apos;&quot; -EA SilentlyContinue)) {
        New-ADOrganizationalUnit -Name $ou -Path $base
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the part that actually caused me the most grief. I originally created my service accounts the normal way and then went back to set their Kerberos encryption types afterward. Every time I touched the account again, whether that was a password reset or an attribute change, it blanked the encryption types back out for some reason.&lt;/p&gt;
&lt;p&gt;The fix is to set the encryption types in the same &lt;code&gt;New-ADUser&lt;/code&gt; call that creates the account, so it&apos;s born correct instead of patched afterward:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$svcOu = &quot;OU=Service Accounts,$base&quot;
$sec   = ConvertTo-SecureString &apos;YourStrongP@ssword!&apos; -AsPlainText -Force
foreach ($u in &apos;svc-sql-engine&apos;,&apos;svc-sql-agent&apos;,&apos;joinadmin&apos;) {
    if (-not (Get-ADUser -Filter &quot;SamAccountName -eq &apos;$u&apos;&quot; -EA SilentlyContinue)) {
        New-ADUser -Name $u -SamAccountName $u -Path $svcOu `
            -UserPrincipalName &quot;$u@lab.local&quot; `
            -AccountPassword $sec -Enabled $true -PasswordNeverExpires $true `
            -KerberosEncryptionType AES128,AES256
    }
}
Add-ADGroupMember &apos;Domain Admins&apos; &apos;joinadmin&apos; -EA SilentlyContinue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;joinadmin&lt;/code&gt; is only there to join the two nodes to the domain. &lt;code&gt;svc-sql-engine&lt;/code&gt; and &lt;code&gt;svc-sql-agent&lt;/code&gt; are what the SQL Server services themselves will run under. You can confirm the encryption types took by checking &lt;code&gt;msDS-SupportedEncryptionTypes&lt;/code&gt; is &lt;code&gt;24&lt;/code&gt; (AES128 + AES256) on each account.&lt;/p&gt;
&lt;h3&gt;Join the SQL nodes to the domain&lt;/h3&gt;
&lt;p&gt;On each node, rename it and reboot first, since the machine name needs to be final before joining.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$NodeName = &apos;SQL25-WIN25-01&apos;   # or &apos;SQL25-WIN25-02&apos; on the second node
Rename-Computer -NewName $NodeName -Force
Restart-Computer -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After the reboot, point the node&apos;s DNS at the DC so the domain actually resolves, then join.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$if = (Get-NetAdapter | Where-Object Status -eq &apos;Up&apos; | Select-Object -First 1).Name
Set-DnsClientServerAddress -InterfaceAlias $if -ServerAddresses 192.168.25.12
Resolve-DnsName lab.local -Server 192.168.25.12

$cred = New-Object System.Management.Automation.PSCredential(
    &apos;joinadmin@lab.local&apos;,
    (ConvertTo-SecureString &apos;YourStrongP@ssword!&apos; -AsPlainText -Force))
Add-Computer -DomainName lab.local -Credential $cred `
    -OUPath &apos;OU=SQL Servers,DC=lab,DC=local&apos;
Restart-Computer -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you&apos;re cloning VM templates like I am, make sure the template was properly sysprepped and generalized before you clone it. My first attempt used a template that hadn&apos;t been generalized, so every clone shared the same machine SID, which collided with the domain SID and made a normal domain join fail outright. The workaround at the time was an offline join with &lt;code&gt;djoin&lt;/code&gt;, but once I fixed the template, &lt;code&gt;Add-Computer&lt;/code&gt; just worked normally. I&apos;m leaving the &lt;code&gt;djoin&lt;/code&gt; steps out of this post since they shouldn&apos;t be necessary if your template is set up correctly, but it&apos;s the error to look for if a live join ever refuses to cooperate.&lt;/p&gt;
&lt;p&gt;After the reboot, verify the join actually took.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-CimInstance Win32_ComputerSystem | Format-List Name,Domain,PartOfDomain
Test-ComputerSecureChannel -Verbose
nltest /dsgetdc:lab.local
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One more wrinkle: joined nodes don&apos;t always self-register their own DNS A record, even on a healthy secure dynamic update zone. An A record is just the DNS entry that maps a hostname to an IP address. Domain-joined machines normally create their own automatically, but that self-registration is a background process that occasionally doesn&apos;t land. Since these nodes have fixed IPs anyway, I just created static A records on the DC instead of chasing why dynamic registration didn&apos;t persist.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Add-DnsServerResourceRecordA -ZoneName lab.local -Name SQL25-WIN25-01 -IPv4Address 192.168.25.13
Add-DnsServerResourceRecordA -ZoneName lab.local -Name SQL25-WIN25-02 -IPv4Address 192.168.25.14
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Repeat everything in this section on the second node before moving on.&lt;/p&gt;
&lt;h2&gt;Phase 3 - SQL Server Service Accounts&lt;/h2&gt;
&lt;p&gt;With both nodes joined, the SQL Server service on each one needs to run under the domain account rather than the local service account it started with. You can do this by hand with WMI, but it&apos;s fiddly to get the Windows-side permissions right, so I just used SQL Server Configuration Manager instead, which handles all of that for you.&lt;/p&gt;
&lt;p&gt;On each node:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;SQL Server Configuration Manager&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;SQL Server Services&lt;/strong&gt; in the left pane.&lt;/li&gt;
&lt;li&gt;Right-click &lt;strong&gt;SQL Server (MSSQLSERVER)&lt;/strong&gt; and choose &lt;strong&gt;Properties&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;Log On&lt;/strong&gt; tab, select &lt;strong&gt;This account&lt;/strong&gt;, then enter &lt;code&gt;LAB\svc-sql-engine&lt;/code&gt; and its password.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;OK&lt;/strong&gt;. Configuration Manager will offer to restart the service. Let it, since the change doesn&apos;t fully take effect until it does.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;SQL Server Agent doesn&apos;t need the domain identity for anything in this post. It only matters if you want scheduled jobs to run as that account, and if you do, repeat the same steps for &lt;strong&gt;SQL Server Agent (MSSQLSERVER)&lt;/strong&gt; using &lt;code&gt;LAB\svc-sql-agent&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If the engine service refuses to start with a 7038 &quot;SID inconsistent with trust&quot; error right after a fresh domain join, a reboot usually clears it, since the LSA needs to re-read the right that was just granted. If it persists, the node&apos;s secure channel is probably broken and worth repairing with &lt;code&gt;Test-ComputerSecureChannel -Repair&lt;/code&gt;. If you ever suspect a stale Kerberos ticket is involved, &lt;code&gt;klist purge&lt;/code&gt; from an elevated prompt clears the cache and forces a fresh one on next login.&lt;/p&gt;
&lt;p&gt;Last for this phase, align the instance settings, since editions and collation need to match across both nodes before you can build the AG.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sqlcmd -S localhost -E -b -Q &quot;EXEC sys.sp_configure &apos;show advanced options&apos;, 1; RECONFIGURE; EXEC sys.sp_configure &apos;max server memory (MB)&apos;, 6000; RECONFIGURE; EXEC sys.sp_configure &apos;max degree of parallelism&apos;, 4; RECONFIGURE;&quot;
sqlcmd -S localhost -E -Q &quot;SELECT SERVERPROPERTY(&apos;Edition&apos;) Edition, SERVERPROPERTY(&apos;ProductLevel&apos;) Patch, SERVERPROPERTY(&apos;Collation&apos;) Collation;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Repeat this whole phase on the second node.&lt;/p&gt;
&lt;h2&gt;Phase 4 - Building the Cluster&lt;/h2&gt;
&lt;p&gt;Availability groups sit on top of a Windows Server Failover Cluster, so that comes next. A quick note on where each of these commands actually runs, since it isn&apos;t always obvious: installing the feature happens on each node separately, but forming the cluster itself is a single command run once from either node, since it acts on the whole cluster rather than one machine at a time.&lt;/p&gt;
&lt;p&gt;On each node, install the failover clustering feature and reboot.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Install-WindowsFeature Failover-Clustering -IncludeManagementTools
Restart-Computer -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the DC, create a file share to act as the witness. Quorum is the cluster&apos;s way of deciding who&apos;s allowed to be in charge when things go wrong, essentially a vote among the nodes plus this witness share, and it exists specifically to prevent &quot;split-brain,&quot; where both nodes think they&apos;re the primary at the same time and start diverging. With only two nodes, a tie is always possible, so the witness acts as a tie-breaking third vote. It&apos;s just a plain SMB file share (a Windows network share, the same kind you&apos;d use to drop a file on another PC). It doesn&apos;t hold any actual data, only a small file the cluster uses to check who currently holds the vote.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;New-Item -Path C:\Shares\sqlclu01-fsw -ItemType Directory -Force | Out-Null
New-SmbShare -Name sqlclu01-fsw -Path C:\Shares\sqlclu01-fsw -FullAccess &apos;LAB\Domain Admins&apos; -EA SilentlyContinue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From either node, validate and then create the cluster. &lt;code&gt;Test-Cluster&lt;/code&gt; will throw storage and network warnings since there&apos;s no shared storage in this setup, which is expected, since availability groups don&apos;t use shared storage the way a traditional failover cluster instance would.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Test-Cluster -Node SQL25-WIN25-01,SQL25-WIN25-02
New-Cluster -Name SQL25-CLU-01 -Node SQL25-WIN25-01,SQL25-WIN25-02 `
    -StaticAddress 192.168.25.15 -NoStorage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The CNO confused me when I first read about needing to create it. When you create the cluster, Windows creates a computer object in AD for the cluster itself, separate from the two node objects, so the cluster has its own identity on the network with its own name (&lt;code&gt;SQL25-CLU-01&lt;/code&gt;) and IP (&lt;code&gt;.15&lt;/code&gt;). That identity is what actually authenticates to the witness share, not either node individually, which is why the permission below has to be granted to &lt;code&gt;SQL25-CLU-01$&lt;/code&gt; (the trailing &lt;code&gt;$&lt;/code&gt; is just how Windows denotes a computer account rather than a user account) and not to the node computer accounts. It needs full rights on the witness share, both at the share level and in NTFS, the Windows filesystem permission layer, or setting the quorum will fail.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Grant-SmbShareAccess -Name sqlclu01-fsw -AccountName &apos;LAB\SQL25-CLU-01$&apos; -AccessRight Full -Force
$cno = &apos;LAB\SQL25-CLU-01$&apos;
icacls C:\Shares\sqlclu01-fsw /grant (&apos;{0}:(OI)(CI)F&apos; -f $cno)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From either node again, set the quorum to node-and-file-share majority and confirm you&apos;re seeing three votes, one for each node plus the witness.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set-ClusterQuorum -NodeAndFileShareMajority \\DC-01\sqlclu01-fsw
Get-ClusterNode | Format-Table Name,NodeWeight,State
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One last thing to handle on the DC before moving on. The AG listener I&apos;ll add later is, like the CNO, its own computer object in AD with its own name and IP, and by default the CNO doesn&apos;t have permission to create one. If you skip this, adding the listener later fails with a fairly opaque &quot;could not bring the Network Name online&quot; error. Grant it now so that step works when you get there.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ou = &apos;OU=SQL Servers,DC=lab,DC=local&apos;
dsacls $ou /I:T /G (&apos;{0}:CC;computer&apos; -f &apos;LAB\SQL25-CLU-01$&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Phase 5 - Enabling Always On&lt;/h2&gt;
&lt;p&gt;On both nodes, enable the Always On feature, which ties the instance to the cluster and restarts the SQL service.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Import-Module SqlServer -EA SilentlyContinue
Enable-SqlAlwaysOn -ServerInstance localhost -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On a new installation, Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-sql-server-network-protocol-configuration&quot;&gt;default network protocol configuration&lt;/a&gt; page shows TCP/IP disabled for Developer and Express editions, while Enterprise and Standard have it enabled. Named Pipes is listed as &quot;disabled for network connections&quot; for every edition, so a fresh Developer install doesn&apos;t accept connections from other machines until TCP/IP is turned on. Ports are just the numbered &quot;doors&quot; a service listens on. 1433 is SQL Server&apos;s normal client port, and 5022 is the port the AG endpoints in this post use to talk to each other. Clients, and the AG listener on 1433, need TCP/IP enabled. If you miss this, SSMS and the failover wizard fail with a Named Pipes error rather than anything that points at TCP being off, which is a confusing error to land on if you don&apos;t know Named Pipes was even involved. The &lt;code&gt;New-NetFirewallRule&lt;/code&gt; lines after it open both ports through Windows Firewall, since the OS firewall drops the traffic unless a rule allows it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$inst = (Get-ItemProperty &apos;HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL&apos;).MSSQLSERVER
$tcp  = &quot;HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$inst\MSSQLServer\SuperSocketNetLib\Tcp&quot;
Set-ItemProperty $tcp -Name Enabled -Value 1
Set-ItemProperty &quot;$tcp\IPAll&quot; -Name TcpPort -Value 1433
Set-ItemProperty &quot;$tcp\IPAll&quot; -Name TcpDynamicPorts -Value &apos;&apos;

New-NetFirewallRule -DisplayName &apos;SQL AG - engine 1433&apos;  -Direction Inbound -Protocol TCP -LocalPort 1433 -Action Allow -EA SilentlyContinue | Out-Null
New-NetFirewallRule -DisplayName &apos;SQL AG - endpoint 5022&apos; -Direction Inbound -Protocol TCP -LocalPort 5022 -Action Allow -EA SilentlyContinue | Out-Null

Restart-Service MSSQLSERVER -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the primary node only, create a test database. It needs to be in full recovery mode with at least one full backup taken before an AG can add it. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/database-engine/availability-groups/windows/automatically-initialize-always-on-availability-group&quot;&gt;automatic seeding documentation&lt;/a&gt; is specific that these backups &quot;are not used for automatic seeding, but they are required before including the database in an availability group.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;New-Item C:\Backups -ItemType Directory -Force | Out-Null
sqlcmd -S localhost -E -C -Q &quot;IF DB_ID(&apos;AgDemo&apos;) IS NULL CREATE DATABASE AgDemo; GO ALTER DATABASE AgDemo SET RECOVERY FULL; GO BACKUP DATABASE AgDemo TO DISK = N&apos;C:\Backups\AgDemo_full.bak&apos; WITH INIT; BACKUP LOG AgDemo TO DISK = N&apos;C:\Backups\AgDemo_log.trn&apos; WITH INIT;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Phase 6 - Creating the Availability Group&lt;/h2&gt;
&lt;p&gt;Since both replicas run under the same domain account, &lt;code&gt;LAB\svc-sql-engine&lt;/code&gt;, the AG endpoints can authenticate over Windows/Kerberos without needing certificates at all. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/database-engine/database-mirroring/transport-security-database-mirroring-always-on-availability&quot;&gt;transport security page&lt;/a&gt; says certificates are required when Windows Authentication isn&apos;t available, for example &quot;when server instances aren&apos;t in trusted domains or when SQL Server is running as a local service.&quot;&lt;/p&gt;
&lt;p&gt;On both nodes, create a SQL login for the service account (running the service as that account doesn&apos;t automatically create a login for it) and set up the endpoint.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sqlcmd -S localhost -E -C -Q &quot;IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = N&apos;LAB\svc-sql-engine&apos;) CREATE LOGIN [LAB\svc-sql-engine] FROM WINDOWS; GO IF NOT EXISTS (SELECT 1 FROM sys.endpoints WHERE name=&apos;Hadr_endpoint&apos;) CREATE ENDPOINT Hadr_endpoint STATE=STARTED AS TCP (LISTENER_PORT=5022, LISTENER_IP=ALL) FOR DATABASE_MIRRORING (ROLE=ALL); GO GRANT CONNECT ON ENDPOINT::Hadr_endpoint TO [LAB\svc-sql-engine];&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the primary, create the availability group with automatic seeding so the database streams to the secondary without a manual backup and restore.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE AVAILABILITY GROUP [ag01]
  WITH (AUTOMATED_BACKUP_PREFERENCE = SECONDARY)
  FOR DATABASE [AgDemo]
  REPLICA ON
    N&apos;SQL25-WIN25-01&apos; WITH (
        ENDPOINT_URL = N&apos;TCP://SQL25-WIN25-01.lab.local:5022&apos;,
        AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, FAILOVER_MODE = AUTOMATIC,
        SEEDING_MODE = AUTOMATIC, BACKUP_PRIORITY = 50,
        SECONDARY_ROLE (ALLOW_CONNECTIONS = ALL)),
    N&apos;SQL25-WIN25-02&apos; WITH (
        ENDPOINT_URL = N&apos;TCP://SQL25-WIN25-02.lab.local:5022&apos;,
        AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, FAILOVER_MODE = AUTOMATIC,
        SEEDING_MODE = AUTOMATIC, BACKUP_PRIORITY = 50,
        SECONDARY_ROLE (ALLOW_CONNECTIONS = ALL));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the secondary, join the AG and grant it permission to create the database, which automatic seeding needs in order to create the database on the secondary.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER AVAILABILITY GROUP [ag01] JOIN;
ALTER AVAILABILITY GROUP [ag01] GRANT CREATE ANY DATABASE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Back on the primary, add the listener, the single stable name and IP clients will actually connect to, so they never need to know or care which node is currently primary. The subnet mask has to be spelled out here in its longhand dotted form rather than the &lt;code&gt;/26&lt;/code&gt; shorthand from earlier. They mean the same thing, but SQL Server&apos;s syntax wants it written out. You&apos;ll also want to make sure the listener&apos;s IP is excluded from your DHCP pool (the range of addresses your router hands out automatically) so nothing else gets assigned that address later and conflicts with it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER AVAILABILITY GROUP [ag01]
  ADD LISTENER N&apos;SQL25-AG-01L&apos; (
    WITH IP ((N&apos;192.168.25.16&apos;, N&apos;255.255.255.192&apos;)), PORT = 1433);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is exactly the step the AD permission from Phase 4 was protecting against. Without granting the CNO rights to create computer objects in that OU, this fails with error 19471. If you hit it anyway, you can pre-stage the listener&apos;s computer object manually on the DC and grant the CNO&apos;s SID full control over it before retrying.&lt;/p&gt;
&lt;p&gt;Last, confirm both replicas are healthy and connected.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ar.replica_server_name, hars.role_desc, hars.synchronization_health_desc, hars.connected_state_desc
FROM sys.dm_hadr_availability_replica_states hars
JOIN sys.availability_replicas ar ON hars.replica_id = ar.replica_id
ORDER BY ar.replica_server_name;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both rows should come back &lt;code&gt;HEALTHY&lt;/code&gt; and &lt;code&gt;CONNECTED&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Phase 7 - Proving Failover Works&lt;/h2&gt;
&lt;p&gt;This is the part I was most looking forward to, since standing up an AG means nothing if you haven&apos;t actually watched it fail over. The listener is the whole point here. Clients should always connect through &lt;code&gt;SQL25-AG-01L.lab.local,1433&lt;/code&gt; and never talk to a node directly.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/ag-failover.BOdli-XA_Z24fgXA.webp&quot; alt=&quot;Animated three-stage diagram of an automatic availability group failover. Stage one, healthy: the client connects through the SQL25-AG-01L listener to SQL25-WIN25-01 as primary, which synchronously commits to SQL25-WIN25-02 as secondary. Stage two, primary lost: SQL25-WIN25-01 is struck out and marked offline, client traffic through the listener stalls, and the file-share witness on DC-01 highlights as the cluster holds quorum. Stage three, failed over: SQL25-WIN25-02 is promoted to primary and the listener re-points to it, restoring the client connection without any change to the connection string.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I started a simple heartbeat writer against the listener, inserting one row a second and printing failures, so any gap in timestamps shows exactly how long a test&apos;s downtime was.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$listener = &apos;SQL25-AG-01L.lab.local&apos;
while ($true) {
    $t = Get-Date -Format &apos;HH:mm:ss.fff&apos;
    $out = sqlcmd -S $listener -E -C -b -Q &quot;INSERT AgDemo.dbo.Heartbeat DEFAULT VALUES;&quot; 2&amp;gt;&amp;amp;1
    if ($LASTEXITCODE -eq 0) { Write-Host &quot;$t  OK&quot; -ForegroundColor Green }
    else                     { Write-Host &quot;$t  FAIL: $out&quot; -ForegroundColor Red }
    Start-Sleep -Milliseconds 1000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With that running in its own window, I ran through a few different tests:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Manual failover.&lt;/strong&gt; From the current secondary, &lt;code&gt;ALTER AVAILABILITY GROUP [ag01] FAILOVER;&lt;/code&gt; triggers a graceful role swap. Downtime was only a couple of seconds and there was zero data loss, which makes sense given synchronous commit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hard primary kill.&lt;/strong&gt; From the Proxmox host, I hard-stopped the primary&apos;s VM with &lt;code&gt;qm stop&lt;/code&gt;, simulating the whole box dying rather than a clean shutdown. Automatic failover kicked in and promoted the secondary.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Witness loss.&lt;/strong&gt; Stopping the file share witness on the DC doesn&apos;t take down the AG, since a two-node cluster still holds quorum on the two node votes alone. It&apos;s just there to break a tie.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Split-brain check.&lt;/strong&gt; Shutting down the secondary, then the primary, then starting only the former secondary back up left the cluster down with just one of three votes, exactly as it should. Node-and-file-share majority refused to auto-start with a minority of votes, which is the whole point of quorum. You can force it back online with &lt;code&gt;Start-ClusterNode -FixQuorum&lt;/code&gt; if you&apos;re willing to accept the split-brain risk, but I only did this to see the protection actually hold.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After each test I confirmed the row count only ever went up, never down, which is what you&apos;d expect from a synchronous-commit AG with no data loss on failover.&lt;/p&gt;
&lt;p&gt;That&apos;s it, though there are a lot of steps. I think most shops have enough automation to set these up faster and more consistently than this. I haven&apos;t set up Ansible for my homelab yet, but I plan to before the Contained AG post, so I don&apos;t have to document every step by hand again. Feel free to leave questions or comments below.&lt;/p&gt;
</content:encoded></item><item><title>Cannot connect to WMI provider in SQL Server Configuration Manager</title><link>https://chandlergray.com/blog/cannot-connect-to-wmi-provider-in-sql-server-configuration-manager/</link><guid isPermaLink="true">https://chandlergray.com/blog/cannot-connect-to-wmi-provider-in-sql-server-configuration-manager/</guid><description>How to fix the &apos;Cannot connect to WMI provider&apos; error in SQL Server Configuration Manager by recompiling the MOF file with mofcomp.</description><pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I logged into a Windows server today to change the startup parameters and was met with this error:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/wmi-provider-error.11fuFIO9_Z24lvGP.webp&quot; alt=&quot;SQL Server Configuration Manager WMI provider error&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Cannot connect to WMI provider. You do not have permissions or the server is unreachable. Note that you can only manage SQL Server 2005 and later servers with SQL Server Configuration Manager
Invalid class [0x80041010]&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Configuration Manager uses Windows Management Instrumentation (WMI) to get the status of the SQL Server services, and it couldn&apos;t connect. The first sentence of the error mentions permissions, but the code at the end is the useful part. Microsoft&apos;s &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/troubleshoot/sql/tools/error-message-when-you-open-configuration-manager&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;article on this error&amp;lt;/a&amp;gt; says both of its possible codes mean &quot;the provider registration is missing rather than that access was denied.&quot; &lt;code&gt;Invalid class&lt;/code&gt; specifically means the WMI namespace for SQL Server is there, but the classes Configuration Manager expects aren&apos;t registered in it. The same article says that registration is removed when an instance of SQL Server is uninstalled, including an instance of a different version on the same machine.&lt;/p&gt;
&lt;p&gt;To fix it, recompile the provider with &lt;code&gt;mofcomp&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I opened an elevated Command Prompt and changed to the SQL Server shared directory for my version. It was a SQL Server 2022 build, so I used:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd &quot;C:\Program Files (x86)\Microsoft SQL Server\160\Shared&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before recompiling anything, I ran this to find the .mof file in the directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dir *.mof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The file name is different between versions of SQL Server. I&apos;d seen &lt;code&gt;sqlmgmproviderxpsp2up.mof&lt;/code&gt; in a different doc, and my folder had &lt;code&gt;sqlmgmprovider.mof&lt;/code&gt;. Microsoft&apos;s article has a table of both. SQL Server 2022 (folder &lt;code&gt;160&lt;/code&gt;) and 2025 (folder &lt;code&gt;170&lt;/code&gt;) use &lt;code&gt;sqlmgmprovider.mof&lt;/code&gt;, and 2005 through 2019 use &lt;code&gt;sqlmgmproviderxpsp2up.mof&lt;/code&gt;. If you aren&apos;t sure which versions are installed, the article also gives a command that lists the .mof files across every version folder:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dir &quot;%programfiles(x86)%\Microsoft SQL Server\*.mof&quot; /s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If no .mof file shows up at all, there&apos;s nothing to recompile, and you&apos;re left with running a repair on the SQL Server installation instead.&lt;/p&gt;
&lt;p&gt;Once I knew which file I had, this is what I ran:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mofcomp sqlmgmprovider.mof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point the problem was fixed for me. Microsoft&apos;s steps do include restarting the WMI service for the change to take effect, so if the error is still there, restart it with this in an elevated PowerShell session:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-Service winmgmt | Restart-Service -Force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restarting the WMI service only takes a few seconds, and from what I found, it doesn&apos;t affect SQL Server, active connections, or running queries. What&apos;s at risk is anything polling through WMI at that moment. Some monitoring agents, PowerShell calls and performance counters all use WMI, so on a production server I&apos;d do it during a low-activity or maintenance window.&lt;/p&gt;
</content:encoded></item><item><title>How To Split a SQL Server Backup File</title><link>https://chandlergray.com/blog/how-to-split-a-sql-server-backup-file/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-split-a-sql-server-backup-file/</guid><description>Learn three ways to split a large SQL Server backup file for migration: native backup striping, PowerShell, and 7-Zip.</description><pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import Callout from &quot;@/components/Callout.astro&quot;;&lt;/p&gt;
&lt;p&gt;Someone came to me today asking for tips on migrating a large database backup from one server to another. The way I&apos;d do it is to break the database backup into parts and upload them via an SFTP server or cloud storage, anywhere that both servers can access. There are three reasons why I think splitting backups into multiple files is better when you&apos;re dealing with multi-terabyte database backup transfers.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Parallelism - Many SFTP and cloud storage services allow multiple uploads at once. With one large file, unless your network connection is the bottleneck, you could be waiting ten times as long or more than you would with several files going at once.&lt;/li&gt;
&lt;li&gt;Resumability - I&apos;ve written in the past how &lt;a href=&quot;https://chandlergray.com/blog/optimizing-sql-server-backup-transfers-with-robocopy/&quot;&gt;network connections can be unreliable&lt;/a&gt;, so having your backup in chunks means if your VPN connection drops, you won&apos;t have to restart your upload from the beginning.&lt;/li&gt;
&lt;li&gt;Size restrictions - Some corporate SFTPs have size limitations for single file uploads. Splitting gets around those limits if your backups exceed them.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I&apos;ll cover three ways to do it: SQL Server&apos;s native split, PowerShell, and 7-Zip.&lt;/p&gt;
&lt;h2&gt;SQL Server Native Split Backup&lt;/h2&gt;
&lt;p&gt;SQL Server can write a single backup across multiple files natively. Add multiple &lt;code&gt;DISK&lt;/code&gt; targets to the &lt;code&gt;BACKUP&lt;/code&gt; command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BACKUP DATABASE AdventureWorks2025
TO DISK = &apos;F:\SQLBackups\AdventureWorks2025_1.bak&apos;,
   DISK = &apos;F:\SQLBackups\AdventureWorks2025_2.bak&apos;,
   DISK = &apos;F:\SQLBackups\AdventureWorks2025_3.bak&apos;
WITH FORMAT, COMPRESSION;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/base-backup.rjf2QjZG_uwno6.webp&quot; alt=&quot;SQL Server Management Studio showing a backup command writing to multiple disk targets&quot; /&gt;&lt;/p&gt;
&lt;p&gt;SQL Server distributes the data across the files roughly evenly. All parts are required for restore. This also parallelizes disk I/O, which can make the backup itself faster.&lt;/p&gt;
&lt;p&gt;That &quot;all parts are required&quot; line is worth taking seriously. Splitting into 18 files gives you 18 files that all have to arrive intact, and one corrupted or missing part leaves you without a usable backup. That holds for all three methods here.&lt;/p&gt;
&lt;p&gt;Microsoft&apos;s documentation calls this a media set rather than a split. Each &lt;code&gt;DISK&lt;/code&gt; target is a &lt;em&gt;media family&lt;/em&gt;, and the &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/backup-restore/media-sets-media-families-and-backup-sets-sql-server&quot;&gt;media sets page&lt;/a&gt; says that when there&apos;s more than one family, &quot;the backup set is distributed among them.&quot; For a restore from disk, &quot;all the media families must be concurrently mounted,&quot; which is the documented way of saying you need every file at once. The number of devices is also fixed once the media set exists, so later backups to the same media set have to use the same number of files.&lt;/p&gt;
&lt;p&gt;Before deleting anything on the source, check the file count and sizes on the destination. For the native split, &lt;code&gt;RESTORE VERIFYONLY&lt;/code&gt; against the full set of &lt;code&gt;DISK&lt;/code&gt; targets checks that the backup set is complete and readable.&lt;/p&gt;
&lt;p&gt;To restore:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RESTORE DATABASE AdventureWorks2025
FROM DISK = &apos;F:\SQLBackups\AdventureWorks2025_1.bak&apos;,
     DISK = &apos;F:\SQLBackups\AdventureWorks2025_2.bak&apos;,
     DISK = &apos;F:\SQLBackups\AdventureWorks2025_3.bak&apos;
WITH RECOVERY;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; You control the backup job and can re-run it. You want the split to happen at backup time, not as a separate step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Limitation:&lt;/strong&gt; You need to decide the number of files up front. If you want each part under, say, 120 GB for a 2 TB backup, you would need at least 18 files to get under that threshold.&lt;/p&gt;
&lt;h3&gt;Ola Hallengren&lt;/h3&gt;
&lt;p&gt;If you use &lt;a href=&quot;https://ola.hallengren.com/sql-server-backup.html&quot;&gt;Ola Hallengren&apos;s maintenance solution&lt;/a&gt; you can do this a couple of ways:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;If you don&apos;t mind exactly how big each file is and just want a specific number of them, add &lt;code&gt;@NumberOfFiles = n&lt;/code&gt; to the backup job, where &lt;code&gt;n&lt;/code&gt; is the number of files you want to back up to.&lt;/li&gt;
&lt;li&gt;If you care about the maximum size of each file, set &lt;code&gt;@MaxFileSize = n&lt;/code&gt; in the backup job instead, where &lt;code&gt;n&lt;/code&gt; is the maximum file size in MB.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/ola-multi-backup.Dn85XHiq_hLkRQ.webp&quot; alt=&quot;Ola Hallengren backup job configured with MaxFileSize parameter&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;PowerShell Byte-Level Split&lt;/h2&gt;
&lt;p&gt;Sometimes you don&apos;t have permission to change the backup job. If the backup already exists as a single file, you can split it after the fact with PowerShell.&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;warning&quot;&amp;gt;
Don&apos;t modify the only copy of the production server&apos;s backup. Copy it to a different workstation before splitting it.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;h3&gt;PowerShell Split&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;$file = &apos;F:\SQLBackups\AdventureWorks2025.bak&apos;
$chunkSize = 16000KB ## Set file size here
$bufferSize = 400KB
$buffer = New-Object byte[] $bufferSize
$reader = [IO.File]::OpenRead($file)
$part = 1
$bytesLeft = $chunkSize

$writer = [IO.File]::OpenWrite(&quot;$file.part$part&quot;)
while (($read = $reader.Read($buffer, 0, [math]::Min($bufferSize, $bytesLeft))) -gt 0) {
    $writer.Write($buffer, 0, $read)
    $bytesLeft -= $read
    if ($bytesLeft -le 0) {
        $writer.Close()
        $part++
        $bytesLeft = $chunkSize
        $writer = [IO.File]::OpenWrite(&quot;$file.part$part&quot;)
    }
}
$writer.Close()
$reader.Close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/powershell-chunk-file.B-XNGVZc_tCsjU.webp&quot; alt=&quot;Powershell chunk file&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;PowerShell Reassemble&lt;/h3&gt;
&lt;p&gt;On the destination, concatenate the parts back together:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$bufferSize = 400KB
$buffer = New-Object byte[] $bufferSize
$out = [IO.File]::OpenWrite(&apos;F:\SQLBackups\AdventureWorks2025_unchunked.bak&apos;)
Get-ChildItem &apos;F:\SQLBackups\AdventureWorks2025.bak.part*&apos; | Sort-Object Name | ForEach-Object {
    $in = [IO.File]::OpenRead($_.FullName)
    while (($read = $in.Read($buffer, 0, $bufferSize)) -gt 0) {
        $out.Write($buffer, 0, $read)
    }
    $in.Close()
}
$out.Close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/powershell-unchunk-file.BOpUUgxs_2nHOpS.webp&quot; alt=&quot;PowerShell unchunk file&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;info&quot;&amp;gt;
&lt;code&gt;$bufferSize&lt;/code&gt; controls how many bytes are read from disk into memory per &lt;code&gt;Read()&lt;/code&gt; call. It has no effect on chunk boundaries, which are set by &lt;code&gt;$chunkSize&lt;/code&gt;. A larger buffer means fewer read and write calls. For a 1 TB file, the script&apos;s 400KB buffer works out to about 2.7 million reads, while a 4KB buffer would take about 268 million. In the split script, each read is also capped at what&apos;s left of the current chunk, so a buffer larger than &lt;code&gt;$chunkSize&lt;/code&gt; doesn&apos;t help there. Keep it well below available RAM.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; The backup already exists as one file and you don&apos;t want to install additional tools.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Limitation:&lt;/strong&gt; It&apos;s more code to maintain than the other two options.&lt;/p&gt;
&lt;h2&gt;7-Zip Split Archive&lt;/h2&gt;
&lt;p&gt;7-Zip can split a file into volumes without compression using store mode:&lt;/p&gt;
&lt;h3&gt;7-Zip Split&lt;/h3&gt;
&lt;p&gt;7-Zip is on its &lt;a href=&quot;https://www.7-zip.org/download.html&quot;&gt;official download page&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;amp; &quot;C:\Program Files\7-Zip\7z.exe&quot; a -v16m -mx0 F:\SQLBackups\AdventureWorks2025.7z F:\SQLBackups\AdventureWorks2025.bak
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-v16m&lt;/code&gt; sets each volume to 16 MB. &lt;code&gt;-mx0&lt;/code&gt; disables compression (store mode), so the file is split as-is with no CPU overhead. Output files are named &lt;code&gt;AdventureWorks2025.7z.001&lt;/code&gt;, &lt;code&gt;AdventureWorks2025.7z.002&lt;/code&gt;, etc.&lt;/p&gt;
&lt;h3&gt;7-Zip Reassemble&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&amp;amp; &quot;C:\Program Files\7-Zip\7z.exe&quot; x F:\SQLBackups\AdventureWorks2025.7z.001 -oF:\SQLBackups\
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Point the extraction at the &lt;code&gt;.001&lt;/code&gt; file. 7-Zip finds the rest of the parts on its own, so they all need to be in the same directory.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When to use this:&lt;/strong&gt; You have an existing backup file, 7-Zip is available, and you want a simple split with a standard archive format the destination can unpack.&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;info&quot;&amp;gt;
If you do want compression, remove the &lt;code&gt;-mx0&lt;/code&gt; flag. For &lt;code&gt;.bak&lt;/code&gt; files this is usually not worth the time and CPU cost on a multi-terabyte file.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;h2&gt;Which Method to Pick&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criteria&lt;/th&gt;
&lt;th&gt;SQL Server Split&lt;/th&gt;
&lt;th&gt;PowerShell&lt;/th&gt;
&lt;th&gt;7-Zip&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requires re-running backup&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adds compression&lt;/td&gt;
&lt;td&gt;WITH COMPRESSION&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Optional (off by default with -mx0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra tooling needed&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;7-Zip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works on non-SQL files&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For a multi-terabyte SQL Server backup going over a constrained SFTP link, 7-Zip with &lt;code&gt;-mx0&lt;/code&gt; is usually the best fit. The split is handled in a single command with no compression overhead. If you can&apos;t install 7-Zip on the source, PowerShell works with no dependencies. If you can re-run the backup, the native SQL Server split avoids post-processing entirely. Only the SQL Server split guarantees the parts are written in a way that SQL Server can read them directly, but all three methods produce files that can be reassembled into a valid backup. With the exception of the native split, there&apos;s no limitation to the file type, so you can use these methods to split and reassemble any large file, not just SQL Server backups.&lt;/p&gt;
&lt;p&gt;One thing this post doesn&apos;t test is how the PowerShell and 7-Zip splits behave on a backup that&apos;s already compressed with &lt;code&gt;WITH COMPRESSION&lt;/code&gt;. Both work on raw bytes rather than on anything SQL Server understands, so the reassembled file should be identical to the original either way, but that&apos;s reasoning rather than a result.&lt;/p&gt;
</content:encoded></item><item><title>Understanding SQL Server Deadlocks Through the Prisoner&apos;s Dilemma</title><link>https://chandlergray.com/blog/understanding-sql-server-deadlocks-through-the-prisoners-dilemma/</link><guid isPermaLink="true">https://chandlergray.com/blog/understanding-sql-server-deadlocks-through-the-prisoners-dilemma/</guid><description>Explaining SQL Server deadlocks with the Prisoner&apos;s Dilemma, plus a repro, lock ordering, RCSI, optimized locking, and retries.</description><pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everyone has written about deadlocks, so I wanted to try something different.&lt;/p&gt;
&lt;h2&gt;The Prisoner&apos;s Dilemma&lt;/h2&gt;
&lt;p&gt;Two suspects are arrested and held in separate rooms. Each is offered a deal to testify against the other and go free while the other serves the full sentence. If both stay silent, both serve a short sentence. If both testify, both serve a long one.&lt;/p&gt;
&lt;p&gt;They can&apos;t talk to each other, so neither knows what the other will do.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Suspect B stays silent&lt;/th&gt;
&lt;th&gt;Suspect B testifies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Suspect A stays silent&lt;/td&gt;
&lt;td&gt;Both serve 1 year&lt;/td&gt;
&lt;td&gt;A serves 10, B goes free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suspect A testifies&lt;/td&gt;
&lt;td&gt;A goes free, B serves 10&lt;/td&gt;
&lt;td&gt;Both serve 5 years&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The rational move for each suspect is to testify, so both testify and both serve five years.&lt;/p&gt;
&lt;h2&gt;Your Transactions Are the Prisoners&lt;/h2&gt;
&lt;p&gt;Transaction A locks &lt;code&gt;SalesOrderHeader&lt;/code&gt; and wants &lt;code&gt;ProductInventory&lt;/code&gt;. Transaction B locks &lt;code&gt;ProductInventory&lt;/code&gt; and wants &lt;code&gt;SalesOrderHeader&lt;/code&gt;. Neither releases what it holds, so both wait.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Transaction B releases&lt;/th&gt;
&lt;th&gt;Transaction B holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transaction A releases&lt;/td&gt;
&lt;td&gt;Both complete&lt;/td&gt;
&lt;td&gt;B completes, A fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction A holds&lt;/td&gt;
&lt;td&gt;A completes, B fails&lt;/td&gt;
&lt;td&gt;Deadlock&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;SQL Server solves this by killing one of them. The victim&apos;s work is rolled back, and the other transaction completes.&lt;/p&gt;
&lt;h2&gt;Producing a Deadlock&lt;/h2&gt;
&lt;p&gt;Here&apos;s a way to produce a deadlock in AdventureWorks2022.&lt;/p&gt;
&lt;p&gt;Open &lt;strong&gt;two separate query windows&lt;/strong&gt; in SSMS (File &amp;gt; New Query, twice), both connected to AdventureWorks2022. Each window is its own connection. Run each statement one at a time, in step order.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/BEGIN_TRAN.Dkxy3TXN_1Ewhoh.webp&quot; alt=&quot;BEGIN TRANSACTION&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Session 1&lt;/th&gt;
&lt;th&gt;Session 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BEGIN TRANSACTION;&lt;/code&gt;&amp;lt;br/&amp;gt;&lt;code&gt;UPDATE Sales.SalesOrderHeader SET Comment = &apos;s1&apos; WHERE SalesOrderID = 43659;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BEGIN TRANSACTION;&lt;/code&gt;&amp;lt;br/&amp;gt;&lt;code&gt;UPDATE Production.ProductInventory SET Quantity = Quantity WHERE ProductID = 1 AND LocationID = 1;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UPDATE Production.ProductInventory SET Quantity = Quantity WHERE ProductID = 1 AND LocationID = 1;&lt;/code&gt;&amp;lt;br/&amp;gt;&lt;em&gt;(hangs, waiting for Session 2)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UPDATE Sales.SalesOrderHeader SET Comment = &apos;s2&apos; WHERE SalesOrderID = 43659;&lt;/code&gt;&amp;lt;br/&amp;gt;&lt;em&gt;(deadlock, 1205)&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;After Step 2, both sessions hold one lock each. After Step 3, Session 1 is blocked waiting on Session 2&apos;s lock.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/blocking.9iUJ5Ar3_Z2adE7A.webp&quot; alt=&quot;Blocked Session&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Step 4 creates what&apos;s called a &quot;circular wait&quot;: Session 1 is waiting on Session 2, and now Session 2 is waiting on Session 1. Neither can make progress because each holds what the other needs. SQL Server&apos;s deadlock monitor finds the cycle and ends one session with error 1205. It isn&apos;t instant. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-deadlocks-guide&quot;&gt;deadlocks guide&lt;/a&gt; says the monitor checks every 5 seconds by default, and more often, down to 100 milliseconds, while it keeps finding deadlocks.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/1205error.BXpbwy2X_ZIxTex.webp&quot; alt=&quot;1205&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can also see the deadlock in the deadlock graph. The same guide says the &lt;code&gt;system_health&lt;/code&gt; Extended Events session, which is on by default, &quot;captures &lt;code&gt;xml_deadlock_report&lt;/code&gt; events,&quot; so there&apos;s nothing extra to set up:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/deadlock_graph.ICyNVIoW_Z1v5N3u.webp&quot; alt=&quot;Deadlock Graph&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This graph is SQL Server&apos;s drawing of the prisoner&apos;s dilemma, with two nodes and arrows pointing at each other. The victim is the one SQL Server chose to kill.&lt;/p&gt;
&lt;h2&gt;The Cooperative Strategy&lt;/h2&gt;
&lt;p&gt;In the prisoner&apos;s dilemma, if the prisoners could agree on a strategy beforehand and trust each other to keep it, staying silent becomes the best choice. In SQL Server, that agreement is called &quot;consistent lock ordering&quot;. The repro deadlocked because Session 1 locked the tables in one order and Session 2 locked them in the opposite order. If both sessions always lock &lt;code&gt;SalesOrderHeader&lt;/code&gt; before &lt;code&gt;ProductInventory&lt;/code&gt;, the circular wait can&apos;t form.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Session 1 (consistent order)&lt;/th&gt;
&lt;th&gt;Session 2 (consistent order)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Locks &lt;code&gt;SalesOrderHeader&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(waiting)&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Locks &lt;code&gt;ProductInventory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(waiting)&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Commits, releases both&lt;/td&gt;
&lt;td&gt;Locks &lt;code&gt;SalesOrderHeader&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Locks &lt;code&gt;ProductInventory&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Commits, releases both&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;There&apos;s no circular wait, so both complete.&lt;/p&gt;
&lt;p&gt;That&apos;s one fix, but if you can&apos;t control the lock order, or deadlocking is still a recurring problem, there are a few other options.&lt;/p&gt;
&lt;h3&gt;Read Committed Snapshot Isolation&lt;/h3&gt;
&lt;p&gt;If you can&apos;t control lock ordering, say the queries come from an ORM or a vendor application, consider enabling Read Committed Snapshot Isolation (RCSI) at the database level.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER DATABASE YourDatabase SET READ_COMMITTED_SNAPSHOT ON;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With RCSI enabled, reads stop taking the row and page locks they used to and instead read the last committed version of the row. It&apos;s close to say reads stop taking shared locks, but not exact. The &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-transaction-locking-and-row-versioning-guide&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;locking and row versioning guide&amp;lt;/a&amp;gt; says &quot;Read operations require only the schema stability (&lt;code&gt;Sch-S&lt;/code&gt;) table level locks and no page or row locks,&quot; so there&apos;s still a lock, it&apos;s just not one that a writer is going to collide with. That removes the most common class of reader-writer deadlocks without changing any query code. Writers still block writers.&lt;/p&gt;
&lt;p&gt;Those row versions get kept in tempdb, and the &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/databases/tempdb-database&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;tempdb documentation&amp;lt;/a&amp;gt; lists version stores among the things tempdb holds, including &quot;row versions that are generated by data modification transactions in a database that uses row versioning-based &lt;code&gt;READ COMMITTED&lt;/code&gt; or &lt;code&gt;SNAPSHOT&lt;/code&gt; isolation transactions.&quot; So this moves work into tempdb rather than removing it, which is worth knowing on an instance where tempdb is already busy.&lt;/p&gt;
&lt;h3&gt;Optimized Locking (SQL Server 2025+ / Azure SQL)&lt;/h3&gt;
&lt;p&gt;If you&apos;re on Azure SQL Database or SQL Server 2025, Optimized Locking changes how the engine holds row locks. Normally a transaction acquires a lock on every row it touches and holds all of them until commit. With &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/performance/optimized-locking?view=sql-server-ver17&quot;&gt;Optimized Locking&lt;/a&gt;, each row lock is released as soon as the row is written, and the transaction instead holds a single lightweight lock on its Transaction ID (TID). Fewer locks held for less time means fewer opportunities for a deadlock cycle to form.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/performance/optimized-locking&quot;&gt;optimized locking documentation&lt;/a&gt; says accelerated database recovery (ADR) must be enabled first. RCSI isn&apos;t required, but the lock after qualification part of optimized locking only works with it on, so it&apos;s worth enabling too:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Accelerated Database Recovery is a prerequisite
ALTER DATABASE AdventureWorks2022 SET ACCELERATED_DATABASE_RECOVERY = ON;

-- RCSI is what gives you the full benefit (Lock After Qualification)
ALTER DATABASE AdventureWorks2022 SET READ_COMMITTED_SNAPSHOT ON;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then enable Optimized Locking:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER DATABASE AdventureWorks2022 SET OPTIMIZED_LOCKING = ON;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&apos;s always enabled on Azure SQL Database and on Azure SQL Managed Instance with the Always-up-to-date or 2025 update policy. On SQL Server 2025 it&apos;s available but off by default, and SQL Server 2022 and older don&apos;t have it.&lt;/p&gt;
&lt;p&gt;If you&apos;re on a version that supports it and you already have RCSI on, as far as I can tell enabling it is low-risk and worth doing. Writer-writer deadlocks on the same rows can still happen. Lock ordering fixes the cause, and Optimized Locking only makes it come up less often.&lt;/p&gt;
&lt;h3&gt;Retry Logic in the Application&lt;/h3&gt;
&lt;p&gt;I find this perfectly fine for most systems, and I see it in the wild more often than RCSI.&lt;/p&gt;
&lt;p&gt;The application gets error 1205 back, so it&apos;s easy to catch and retry. SQL Server rolls back the victim and leaves the other transaction intact. A simple retry loop in the application handles it.&lt;/p&gt;
&lt;p&gt;If you&apos;re hitting deadlocks often enough that retries matter, the actual problem is still there and worth addressing. It just depends on how you define &quot;often&quot; and &quot;matter&quot;. If it&apos;s a rare occurrence and the cost of retries is low, this might be good enough.&lt;/p&gt;
&lt;h3&gt;Controlling the Victim&lt;/h3&gt;
&lt;p&gt;By default, SQL Server picks the transaction that&apos;s least expensive to roll back. You can override that with &lt;code&gt;SET DEADLOCK_PRIORITY&lt;/code&gt;, and the priority is checked first. The &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/statements/set-deadlock-priority-transact-sql&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;documentation&amp;lt;/a&amp;gt; says &quot;If the sessions have different deadlock priorities, the session with the lowest deadlock priority is chosen as the deadlock victim,&quot; and when the priorities match, SQL Server &quot;chooses the session that is less expensive to roll back.&quot;&lt;/p&gt;
&lt;p&gt;The range is -10 to 10, and &lt;code&gt;NORMAL&lt;/code&gt;, the default, is 0. So if nobody has set a priority, every session is at 0 and the choice comes down to rollback cost. The deadlocks guide adds that if the priority and the cost are both the same, &quot;a victim is chosen randomly.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Make this session the preferred deadlock victim
SET DEADLOCK_PRIORITY LOW;   -- equivalent to -5

-- Protect this session from being chosen
SET DEADLOCK_PRIORITY HIGH;  -- equivalent to 5

-- Set a specific numeric value
SET DEADLOCK_PRIORITY -3;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is useful when one transaction is &lt;strong&gt;cheap to retry&lt;/strong&gt; and another is expensive. Set the cheap one to LOW and SQL Server will consistently sacrifice it, keeping the expensive one alive. It&apos;s an interesting option, but I&apos;ve never seen it used in production. That doesn&apos;t make it bad, just uncommon.&lt;/p&gt;
&lt;p&gt;The key takeaway from all of this is that frequent deadlocks are a sign that something is wrong. Two transactions acquiring the same resources in opposite order will produce one eventually. Retry logic, deadlock priority and optimized locking make it less likely or less costly, but fixing the order is what removes the cycle.&lt;/p&gt;
</content:encoded></item><item><title>How to Migrate Data Using bcp</title><link>https://chandlergray.com/blog/how-to-migrate-data-using-bcp/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-migrate-data-using-bcp/</guid><description>Step-by-step guide to bulk copying a date-filtered SQL Server table to another server using bcp format files and native mode.</description><pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import Callout from &quot;@/components/Callout.astro&quot;;&lt;/p&gt;
&lt;p&gt;On a recent cloud migration, I needed to move one year of data from one table on a client&apos;s servers to another SQL Server hosted in a VM in the cloud. The table already existed on the destination, so I only needed to move the data. The problem was that the VM we were moving into didn&apos;t have enough space to restore the entire database, so a full &lt;code&gt;BACKUP&lt;/code&gt;/&lt;code&gt;RESTORE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; wasn&apos;t an option. I used &lt;code&gt;bcp&lt;/code&gt; because it didn&apos;t require installing anything on the client&apos;s server.&lt;/p&gt;
&lt;p&gt;The first thing I ran into was a certificate chain error when bcp connected to the local instance. The fix was the &lt;code&gt;-u&lt;/code&gt; flag, which tells bcp to trust the server certificate. I added it to every command after that. According to the &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/tools/bcp-utility&quot;&gt;bcp documentation&lt;/a&gt;, &lt;code&gt;-u&lt;/code&gt; needs bcp version 18 or later, which ships with SQL Server 2025, and &lt;code&gt;bcp -v&lt;/code&gt; shows which version you have.&lt;/p&gt;
&lt;p&gt;Next, I generated a format file. It maps the columns in the export to the columns in the destination table, and it&apos;s worth doing first so you can inspect it before any data moves.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bcp AdventureWorks2022.Person.Person format nul -S localhost -T -n -f person.fmt -u
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the format file exports successfully, the CMD window doesn&apos;t print anything. You can review it by opening the &lt;code&gt;.fmt&lt;/code&gt; file in Notepad, and it should look something like this:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/person_fmt.Cs8CIUvO_Z11W8MK.webp&quot; alt=&quot;format file output&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;warning&quot;&amp;gt;
If you use a three-part name like &lt;code&gt;AdventureWorks2022.Person.Person&lt;/code&gt;, don&apos;t also pass &lt;code&gt;-d&lt;/code&gt; with the database name. bcp will error, telling you that you can&apos;t specify the database name twice.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;p&gt;Before running the full year, I tested with one week to make sure the format file was right and the row counts matched.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bcp &quot;SELECT * FROM AdventureWorks2022.Person.Person WHERE ModifiedDate &amp;gt;= &apos;2013-01-01&apos; AND ModifiedDate &amp;lt; &apos;2013-01-08&apos;&quot; queryout person_test.bcp -S localhost -T -f person.fmt -u
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/week_test.BsBKRtTz_2dxeBl.webp&quot; alt=&quot;test one week&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Once that checked out, I ran the full year:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bcp &quot;SELECT * FROM AdventureWorks2022.Person.Person WHERE ModifiedDate &amp;gt;= &apos;2013-01-01&apos; AND ModifiedDate &amp;lt; &apos;2014-01-01&apos;&quot; queryout person_2013.bcp -S localhost -T -f person.fmt -u
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/full_year.CffJ02zf_1jKLAE.webp&quot; alt=&quot;full year export&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I&apos;m always pleasantly surprised by how fast bcp is. This export of 27,000 rows took about 3 seconds, and I&apos;ve had exports of millions of rows take just a few minutes.&lt;/p&gt;
&lt;p&gt;I copied the &lt;code&gt;.bcp&lt;/code&gt; and &lt;code&gt;.fmt&lt;/code&gt; files to the destination machine and ran the import. The table had an identity column and I needed to keep the original values, so I passed &lt;code&gt;-E&lt;/code&gt;. I also set the destination database to simple recovery beforehand to keep log growth manageable.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;-E&lt;/code&gt; has a permissions requirement worth knowing about if you&apos;re working with a limited account on someone else&apos;s server. The documentation says a &lt;code&gt;bcp in&lt;/code&gt; &quot;minimally requires &lt;code&gt;SELECT&lt;/code&gt; and &lt;code&gt;INSERT&lt;/code&gt; permissions on the target table,&quot; and that &lt;code&gt;ALTER TABLE&lt;/code&gt; is also required when you use &lt;code&gt;-E&lt;/code&gt; to keep identity values. The same list has two other cases that need &lt;code&gt;ALTER TABLE&lt;/code&gt;: the table has constraints and you haven&apos;t passed the &lt;code&gt;CHECK_CONSTRAINTS&lt;/code&gt; hint, or it has triggers and you haven&apos;t passed &lt;code&gt;FIRE_TRIGGERS&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bcp AdventureWorks2022_dest.Person.Person in person_2013.bcp -S localhost -T -f person.fmt -u -E -b 10000 -e person_errors.txt -q
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/full_year.CffJ02zf_1jKLAE.webp&quot; alt=&quot;full year import&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;info&quot;&amp;gt;
Some guides recommend &lt;code&gt;-h TABLOCK&lt;/code&gt; to reduce lock overhead during bulk inserts. In my case the database wasn&apos;t actively being used during the migration, so it didn&apos;t matter either way.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;-b 10000&lt;/code&gt; sets a batch size. By default, bcp imports every row in the file as one batch, so a failure near the end rolls back the whole import. With a batch size set, each batch is its own transaction, and the documentation says that &quot;if the transaction for any batch fails, only insertions from the current batch are rolled back.&quot; Batches that already committed stay in the table.&lt;/p&gt;
&lt;p&gt;After the import, I ran &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; with the same &lt;code&gt;WHERE&lt;/code&gt; clause on both servers to confirm the numbers matched, checked that the error file was empty, and ran &lt;code&gt;UPDATE STATISTICS&lt;/code&gt; on the destination table.&lt;/p&gt;
&lt;p&gt;I don&apos;t have to do this very often, but it&apos;s nice to have in the toolkit for when I do.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Flags I Used&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-S&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server and instance name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-T&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Windows integrated auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Native binary format. Only needed when generating the format file. Omit it when using &lt;code&gt;-f&lt;/code&gt;, or bcp warns that &lt;code&gt;-f&lt;/code&gt; overrides it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Path to the format file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-E&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep identity values from the file instead of generating new ones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Batch size in rows, so each batch commits as its own transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-e&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;File to write rejected rows and error reasons to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Table hints passed to the bulk insert operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-q&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs &lt;code&gt;SET QUOTED_IDENTIFIER ON&lt;/code&gt; for the connection. Needed for names with spaces or quotes, and &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/statements/set-quoted-identifier-transact-sql&quot;&gt;inserts fail without it&lt;/a&gt; on tables with indexes on computed columns or indexed views&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-u&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Trust the server certificate (bcp 18 or later)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The full flag reference is in the &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/tools/bcp-utility&quot;&gt;bcp documentation&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item><item><title>How to Timeout an External Process in PowerShell</title><link>https://chandlergray.com/blog/how-to-timeout-an-external-process-in-powershell/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-timeout-an-external-process-in-powershell/</guid><description>How to use WaitForExit(Int32) to give an external process a time limit in PowerShell, and what to do when it runs out.</description><pubDate>Thu, 05 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I was troubleshooting a PowerShell script that was part of an automation pipeline today. For the most part, everything ran fine, but I noticed one instance where the process would hang. The only way I was able to stop it was to kill it from Task Manager. I knew the automation typically takes 5-6 seconds per run, so I wanted the script to time itself out after 30 seconds.&lt;/p&gt;
&lt;p&gt;Surprisingly, I&apos;d never had to do anything like this before. After a little research, I found the &lt;a href=&quot;https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.waitforexit&quot;&gt;WaitForExit(Int32)&lt;/a&gt; method, which I thought was perfect.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;WaitForExit(Int32)&lt;/code&gt; is a method on the &lt;code&gt;System.Diagnostics.Process&lt;/code&gt; object. It blocks the current thread for up to the number of milliseconds you give it, then returns &lt;code&gt;true&lt;/code&gt; if the process exited in that window or &lt;code&gt;false&lt;/code&gt; if it didn&apos;t. In PowerShell, you get the process object by passing &lt;code&gt;-PassThru&lt;/code&gt; to &lt;code&gt;Start-Process&lt;/code&gt;, which returns the object instead of discarding it. From there you call &lt;code&gt;WaitForExit(30000)&lt;/code&gt; for a 30-second limit, check the return value, and call &lt;code&gt;Kill()&lt;/code&gt; if it came back &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The process isn&apos;t killed automatically when the timeout runs out, so that step has to be written out. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.kill&quot;&gt;documentation for &lt;code&gt;Kill()&lt;/code&gt;&lt;/a&gt; says it &quot;executes asynchronously,&quot; so calling &lt;code&gt;WaitForExit()&lt;/code&gt; right after makes sure the process has fully stopped before the script continues. Calling &lt;code&gt;Close()&lt;/code&gt; at the end frees the resources held for the exited process.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example of how I used it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$proc = Start-Process python -ArgumentList &quot;automation.py&quot; -PassThru

if (-not $proc.WaitForExit(30000)) {
    $proc.Kill()
    $proc.WaitForExit()
    Write-Host &quot;Timed out. Process killed.&quot;
} else {
    Write-Host &quot;Done. Exit code: $($proc.ExitCode)&quot;
}

$proc.Close()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&apos;s important to note that there are two versions of &lt;code&gt;WaitForExit&lt;/code&gt;, one with a parameter and one without. What &lt;code&gt;WaitForExit(Int32)&lt;/code&gt; gives you that the no-argument version doesn&apos;t is a decision point. With &lt;code&gt;WaitForExit()&lt;/code&gt;, your script hands control to the process and doesn&apos;t get it back until the process exits, which the documentation describes as waiting &quot;indefinitely.&quot; If the process hangs, your script hangs with it. With &lt;code&gt;WaitForExit(30000)&lt;/code&gt;, you get control back after 30 seconds regardless. The process is still running at that point. Nothing interrupted it, you just stopped waiting for it. That&apos;s when you decide what to do next. In this case, I kill it, but you could also log it, retry it, or skip it.&lt;/p&gt;
&lt;p&gt;To make this concrete, say your script normally finishes in 5 seconds but occasionally gets stuck. With &lt;code&gt;WaitForExit()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$proc = Start-Process python -ArgumentList &quot;automation.py&quot; -PassThru
$proc.WaitForExit()
Write-Host &quot;Done.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the script hangs, &lt;code&gt;Write-Host &quot;Done.&quot;&lt;/code&gt; never runs, and your pipeline stops there. With &lt;code&gt;WaitForExit(30000)&lt;/code&gt;, a normal 5-second run returns &lt;code&gt;true&lt;/code&gt; after 5 seconds and lands in the &lt;code&gt;else&lt;/code&gt; branch. If it hangs, you get control back after 30 seconds, kill it, and the rest of your script continues.&lt;/p&gt;
&lt;p&gt;The no-argument &lt;code&gt;WaitForExit()&lt;/code&gt; still has its place, like right after &lt;code&gt;Kill()&lt;/code&gt;, where you aren&apos;t setting a time limit but confirming the process is gone. What you want to avoid is using it for the timeout itself, where you need a way out if the process hangs.&lt;/p&gt;
&lt;p&gt;One more detail from the &lt;code&gt;Kill()&lt;/code&gt; documentation. &lt;code&gt;Kill()&lt;/code&gt; stops only the process you started, not any processes it started in turn. On .NET Core 3.0 and later, there&apos;s a &lt;code&gt;Kill(true)&lt;/code&gt; overload that also stops the process&apos;s descendants. The same page notes that &lt;code&gt;WaitForExit()&lt;/code&gt; only reflects the process itself, not its descendants, even when you use &lt;code&gt;Kill(true)&lt;/code&gt;.&lt;/p&gt;
</content:encoded></item><item><title>Wide vs Narrow Table Design in SQL Server</title><link>https://chandlergray.com/blog/wide-vs-narrow-table-design-in-sql-server/</link><guid isPermaLink="true">https://chandlergray.com/blog/wide-vs-narrow-table-design-in-sql-server/</guid><description>A practical benchmark of month-as-columns vs month-as-rows using SQL Server IO, time, plans, and storage.</description><pubDate>Sun, 15 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This post benchmarks a common modeling decision in SQL Server: should monthly values live as columns in a wide row, or as rows in a narrow fact-style table?&lt;/p&gt;
&lt;p&gt;I took monthly sales and modeled it two ways. In &lt;code&gt;Sales_Wide&lt;/code&gt;, months are columns (&lt;code&gt;Jan&lt;/code&gt; through &lt;code&gt;Dec&lt;/code&gt;). In &lt;code&gt;Sales_Narrow&lt;/code&gt;, month is data (&lt;code&gt;SalesMonth&lt;/code&gt;, &lt;code&gt;Amount&lt;/code&gt;). I loaded 10,000 products across 5 years (2022-2026), which gave me 50,000 rows in &lt;code&gt;Sales_Wide&lt;/code&gt; and 600,000 rows in &lt;code&gt;Sales_Narrow&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For measurement, I used &lt;code&gt;SET STATISTICS IO, TIME ON&lt;/code&gt; and looked at actual plan shape (scan vs seek). Then I ran the same business questions against both models: a single-month total and a Jan-Dec yearly breakdown.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT SUM(Mar)
FROM Sales_Wide
WHERE SalesYear = 2026;

SELECT SUM(Amount)
FROM Sales_Narrow
WHERE SalesMonth = &apos;2026-03-01&apos;;

SELECT
    SUM(Jan), SUM(Feb), SUM(Mar), SUM(Apr),
    SUM(May), SUM(Jun), SUM(Jul), SUM(Aug),
    SUM(Sep), SUM(Oct), SUM(Nov), SUM(Dec)
FROM Sales_Wide
WHERE SalesYear = 2026;

SELECT
    SUM(CASE WHEN MONTH(SalesMonth) = 1 THEN Amount END) AS Jan,
    SUM(CASE WHEN MONTH(SalesMonth) = 2 THEN Amount END) AS Feb,
    SUM(CASE WHEN MONTH(SalesMonth) = 3 THEN Amount END) AS Mar,
    SUM(CASE WHEN MONTH(SalesMonth) = 4 THEN Amount END) AS Apr,
    SUM(CASE WHEN MONTH(SalesMonth) = 5 THEN Amount END) AS May,
    SUM(CASE WHEN MONTH(SalesMonth) = 6 THEN Amount END) AS Jun,
    SUM(CASE WHEN MONTH(SalesMonth) = 7 THEN Amount END) AS Jul,
    SUM(CASE WHEN MONTH(SalesMonth) = 8 THEN Amount END) AS Aug,
    SUM(CASE WHEN MONTH(SalesMonth) = 9 THEN Amount END) AS Sep,
    SUM(CASE WHEN MONTH(SalesMonth) = 10 THEN Amount END) AS Oct,
    SUM(CASE WHEN MONTH(SalesMonth) = 11 THEN Amount END) AS Nov,
    SUM(CASE WHEN MONTH(SalesMonth) = 12 THEN Amount END) AS Dec
FROM Sales_Narrow
WHERE SalesMonth &amp;gt;= &apos;2026-01-01&apos;
  AND SalesMonth &amp;lt; &apos;2027-01-01&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before adding secondary indexes, narrow paid a large penalty because the month filter could not seek cleanly. You can see that immediately in the numbers.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query (lower is better)&lt;/th&gt;
&lt;th&gt;Wide (No index)&lt;/th&gt;
&lt;th&gt;Narrow (No index)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;March logical reads&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;710&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1790&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;March elapsed ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yearly logical reads&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;710&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1790&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yearly elapsed ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;46&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;131&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/baseline-narrow-march-scan-plan.BAonyjbF_1t3jJc.webp&quot; alt=&quot;Narrow March query scanning clustered PK before secondary index&quot; /&gt;
&lt;img src=&quot;https://chandlergray.com/_astro/baseline-narrow-march-io-time.Du3tg_VQ_1CLU7v.webp&quot; alt=&quot;Narrow March query IO/time output before secondary index&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Then I added the two indexes that match how these queries filter.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE INDEX IX_Sales_Narrow_SalesMonth
ON Sales_Narrow (SalesMonth)
INCLUDE (Amount);

CREATE INDEX IX_Sales_Wide_SalesYear
ON Sales_Wide (SalesYear)
INCLUDE (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After that, the comparison became much clearer. &lt;code&gt;Sales_Narrow&lt;/code&gt; is excellent for single-month lookup, while &lt;code&gt;Sales_Wide&lt;/code&gt; is still better for this fixed Jan-Dec report shape.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query (lower is better)&lt;/th&gt;
&lt;th&gt;Wide (indexed)&lt;/th&gt;
&lt;th&gt;Narrow (indexed)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;March logical reads&lt;/td&gt;
&lt;td&gt;144&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;March CPU ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;March elapsed ms&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yearly logical reads&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;144&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;317&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yearly CPU ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yearly elapsed ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;116&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Those results line up with the plans: both models move to seeks, but they still do different amounts of work for different query shapes.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/indexed-wide-yearly-seek-plan.rWwaJRqQ_Z15tqx3.webp&quot; alt=&quot;Wide yearly query using IX_Sales_Wide_SalesYear&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/indexed-narrow-yearly-seek-plan.B5T9krVj_C9U00.webp&quot; alt=&quot;Narrow yearly query using IX_Sales_Narrow_SalesMonth&quot; /&gt;&lt;/p&gt;
&lt;p&gt;There is also a storage tradeoff. The narrow model has more rows and, with supporting indexes, it consumes more total space.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Reserved (before idx)&lt;/th&gt;
&lt;th&gt;Reserved (after idx)&lt;/th&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Index Size (after idx)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sales_Wide&lt;/td&gt;
&lt;td&gt;50000&lt;/td&gt;
&lt;td&gt;5832 KB&lt;/td&gt;
&lt;td&gt;11824 KB&lt;/td&gt;
&lt;td&gt;5640 KB&lt;/td&gt;
&lt;td&gt;5672 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sales_Narrow&lt;/td&gt;
&lt;td&gt;600000&lt;/td&gt;
&lt;td&gt;14472 KB&lt;/td&gt;
&lt;td&gt;27376 KB&lt;/td&gt;
&lt;td&gt;14248 KB&lt;/td&gt;
&lt;td&gt;12632 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/indexed-spaceused.6o2fzFDK_286rG3.webp&quot; alt=&quot;Space usage after adding both secondary indexes&quot; /&gt;&lt;/p&gt;
&lt;p&gt;So what is better? In this demo, both are better at different things. If your core workload is a fixed cross-tab by year, wide is leaner for that output. If your core workload is time-centric filtering and slicing, narrow is the more natural model, as long as you index for that access path.&lt;/p&gt;
&lt;p&gt;The practical takeaway is straightforward: model for the questions you actually ask, and back that model with the indexes those questions need.&lt;/p&gt;
&lt;p&gt;One final note: if this same workload moved from monthly data to daily data, this tradeoff would usually tilt further toward the narrow pattern. A wide design based on time buckets gets harder to maintain and query as granularity increases, while a date-based narrow model stays consistent and indexable. We kept this demo monthly on purpose so the side-by-side comparison stays simple.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Repro setup (create and load demo data)
USE master;
GO

IF DB_ID(&apos;RowsVsColumnsDemo2026&apos;) IS NULL
    CREATE DATABASE RowsVsColumnsDemo2026;
GO

USE RowsVsColumnsDemo2026;
GO

DROP TABLE IF EXISTS Sales_Narrow;
DROP TABLE IF EXISTS Sales_Wide;
GO

CREATE TABLE Sales_Wide (
    ProductID int NOT NULL,
    SalesYear int NOT NULL,
    Jan money NULL,
    Feb money NULL,
    Mar money NULL,
    Apr money NULL,
    May money NULL,
    Jun money NULL,
    Jul money NULL,
    Aug money NULL,
    Sep money NULL,
    Oct money NULL,
    Nov money NULL,
    Dec money NULL,
    CONSTRAINT PK_Sales_Wide
        PRIMARY KEY (ProductID, SalesYear)
);

CREATE TABLE Sales_Narrow (
    ProductID int NOT NULL,
    SalesMonth date NOT NULL,
    Amount money NOT NULL,
    CONSTRAINT PK_Sales_Narrow
        PRIMARY KEY (ProductID, SalesMonth)
);

WITH P AS (
    SELECT TOP (10000)
        ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS ProductID
    FROM sys.objects a CROSS JOIN sys.objects b
)
INSERT INTO Sales_Wide
SELECT
    p.ProductID,
    y.SalesYear,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000,
    ABS(CHECKSUM(NEWID())) % 10000
FROM P p
CROSS JOIN (VALUES (2022),(2023),(2024),(2025),(2026)) y(SalesYear);

INSERT INTO Sales_Narrow (ProductID, SalesMonth, Amount)
SELECT
    ProductID,
    DATEFROMPARTS(SalesYear, MonthNum, 1),
    Amount
FROM Sales_Wide
UNPIVOT (
    Amount FOR MonthName IN
    (Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec)
) u
CROSS APPLY (
    VALUES (CASE MonthName
        WHEN &apos;Jan&apos; THEN 1 WHEN &apos;Feb&apos; THEN 2 WHEN &apos;Mar&apos; THEN 3
        WHEN &apos;Apr&apos; THEN 4 WHEN &apos;May&apos; THEN 5 WHEN &apos;Jun&apos; THEN 6
        WHEN &apos;Jul&apos; THEN 7 WHEN &apos;Aug&apos; THEN 8 WHEN &apos;Sep&apos; THEN 9
        WHEN &apos;Oct&apos; THEN 10 WHEN &apos;Nov&apos; THEN 11 WHEN &apos;Dec&apos; THEN 12
    END)
) m(MonthNum);

SET STATISTICS IO, TIME ON;
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>How to Merge SQL Server Data Files</title><link>https://chandlergray.com/blog/how-to-merge-sql-server-data-files/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-merge-sql-server-data-files/</guid><description>Taking a database with one MDF and several NDFs, and gradually moving all data back into a single data file using `EMPTYFILE`.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I ran &lt;code&gt;DBCC SHRINKFILE&lt;/code&gt; with &lt;code&gt;EMPTYFILE&lt;/code&gt; against a 400GB database in FULL recovery and watched the transaction log go from about 20GB to nearly 200GB before I killed it. The operation is fully logged, one page at a time, and in FULL recovery every one of those page moves is sitting in the log waiting on a log backup. That&apos;s the thing I&apos;d want to know before starting, so it&apos;s going first.&lt;/p&gt;
&lt;p&gt;The database that got me here had one &lt;code&gt;.mdf&lt;/code&gt; and seven &lt;code&gt;.ndf&lt;/code&gt; files. That&apos;s not unusual in older systems and it&apos;s usually not necessary now. The workload wasn&apos;t pushing any storage limits and the disk was modern enough to handle a single data file, so the plan was to restore it as-is and then consolidate.&lt;/p&gt;
&lt;p&gt;Multiple data files used to spread I/O across spinning disks, which was a real benefit at the time. On SSDs and SANs that advantage is typically small or gone, and managing the extra files costs more than it returns.&lt;/p&gt;
&lt;p&gt;The screenshots below come from a small test database with the same kind of layout, one &lt;code&gt;.mdf&lt;/code&gt; and three &lt;code&gt;.ndf&lt;/code&gt; files, and I&apos;ve included the script that builds it so you can follow along without touching anything real.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why This Database Had Eight Files&lt;/h2&gt;
&lt;p&gt;SQL Server stores data inside filegroups, which are made up of logical files that point to the actual &lt;code&gt;.mdf&lt;/code&gt; or &lt;code&gt;.ndf&lt;/code&gt; files on disk. When you restore a backup, SQL Server expects every file in that backup to exist. There&apos;s no way to merge files during the restore itself. You have to bring the database online first, then consolidate afterward.&lt;/p&gt;
&lt;p&gt;I prefer to collapse files when they&apos;re just leftover structure from an older environment:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You&apos;re no longer on spinning rust.&lt;/li&gt;
&lt;li&gt;There&apos;s no real multi-volume layout behind them anymore.&lt;/li&gt;
&lt;li&gt;The extra files make backups, monitoring, and restores more complicated than they need to be.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Multiple data files still make sense in some scenarios (TempDB, multiple storage tiers, massive OLTP), but in a lot of line-of-business systems they’re just historical clutter.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Demo Setup: Build a Playground With Multiple Data Files&lt;/h2&gt;
&lt;p&gt;Here&apos;s a self-contained lab script you can run on a dev instance. It creates a database with one MDF and three NDFs, loads some data, and gives you something to merge.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USE master;
GO

IF DB_ID(&apos;DemoMergeFiles&apos;) IS NOT NULL
BEGIN
    ALTER DATABASE DemoMergeFiles SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
    DROP DATABASE DemoMergeFiles;
END
GO

CREATE DATABASE DemoMergeFiles
ON PRIMARY
(
    NAME = N&apos;DemoMergeFiles_Primary&apos;,
    FILENAME = N&apos;F:\SQLData\DemoMergeFiles_Primary.mdf&apos;,
    SIZE = 200MB,
    FILEGROWTH = 50MB
),
(
    NAME = N&apos;DemoMergeFiles_NDF1&apos;,
    FILENAME = N&apos;F:\SQLData\DemoMergeFiles_NDF1.ndf&apos;,
    SIZE = 200MB,
    FILEGROWTH = 50MB
),
(
    NAME = N&apos;DemoMergeFiles_NDF2&apos;,
    FILENAME = N&apos;F:\SQLData\DemoMergeFiles_NDF2.ndf&apos;,
    SIZE = 200MB,
    FILEGROWTH = 50MB
),
(
    NAME = N&apos;DemoMergeFiles_NDF3&apos;,
    FILENAME = N&apos;F:\SQLData\DemoMergeFiles_NDF3.ndf&apos;,
    SIZE = 200MB,
    FILEGROWTH = 50MB
)
LOG ON
(
    NAME = N&apos;DemoMergeFiles_Log&apos;,
    FILENAME = N&apos;F:\SQLData\DemoMergeFiles_Log.ldf&apos;,
    SIZE = 200MB,
    FILEGROWTH = 50MB
);
GO

ALTER DATABASE DemoMergeFiles SET RECOVERY SIMPLE;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Adjust paths to match wherever your instance keeps data and log files.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Load Some Data So The Files Actually Get Used&lt;/h2&gt;
&lt;p&gt;The table is wide on purpose, so its pages spread across every file in the filegroup.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USE DemoMergeFiles;
GO

IF OBJECT_ID(&apos;dbo.BigDemo&apos;, &apos;U&apos;) IS NOT NULL
    DROP TABLE dbo.BigDemo;
GO

CREATE TABLE dbo.BigDemo
(
    Id INT IDENTITY(1,1) PRIMARY KEY CLUSTERED,
    SomeText CHAR(4000) NOT NULL,
    SomeNumber INT NOT NULL,
    CreatedAt DATETIME2 NOT NULL
);
GO

-- Adjust this to make EMPTYFILE take longer or shorter.
DECLARE @TargetRows INT = 500000;

WITH Numbers AS
(
    SELECT TOP (@TargetRows)
           ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
    FROM sys.all_objects AS a
    CROSS JOIN sys.all_objects AS b
)
INSERT dbo.BigDemo (SomeText, SomeNumber, CreatedAt)
SELECT REPLICATE(&apos;X&apos;, 4000),
       n % 1000,
       DATEADD(SECOND, n, &apos;2025-01-01&apos;)
FROM Numbers;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On my test system, half a million rows was enough to put data in every file without taking long to load.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/Screenshot-2025-12-02-193437.Ci8CMDL6_Z1Wllup.webp&quot; alt=&quot;SQL Server Management Studio showing the BigDemo table with 500,000 rows inserted across multiple data files&quot; /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Determine File Usage Patterns&lt;/h2&gt;
&lt;p&gt;After the restore, or the demo setup above, the first thing I look at is the file layout and how much space each file is actually using.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USE DemoMergeFiles;
GO

SELECT  name,
        type_desc,
        physical_name,
        size * 8 / 1024 AS SizeMB,
        FILEPROPERTY(name, &apos;SpaceUsed&apos;) * 8 / 1024 AS UsedMB
FROM sys.database_files;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/Screenshot-2025-12-02-193544.BvIKGFxH_1mHWCq.webp&quot; alt=&quot;Query results showing the file layout with four data files (one MDF and three NDFs) and their size and space used in megabytes&quot; /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Pre-Migration Safety Preparations&lt;/h2&gt;
&lt;p&gt;In production I do a few things before touching the file structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Make sure there&apos;s a recent backup and that it actually restores.&lt;/li&gt;
&lt;li&gt;Switch to SIMPLE recovery if the database can go without log backups for a while. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/backup-restore/view-or-change-the-recovery-model-of-a-database-sql-server&quot;&gt;recovery model page&lt;/a&gt; says to back up the log before switching away from FULL, and after switching back to take &quot;a full or differential database backup to start the log chain,&quot; so I plan for both of those backups as part of the job.&lt;/li&gt;
&lt;li&gt;Pick a time without long-running queries or index rebuilds. Moving an IAM page needs a schema modification (&lt;code&gt;Sch-M&lt;/code&gt;) lock, and the &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-shrinkfile-transact-sql&quot;&gt;&lt;code&gt;DBCC SHRINKFILE&lt;/code&gt; documentation&lt;/a&gt; says &quot;long-running queries can block a shrink operation,&quot; and that &quot;any new query requiring a &lt;code&gt;Sch-S&lt;/code&gt; lock on an IAM page can queue behind the shrink operation.&quot; On SQL Server 2022 and later there&apos;s &lt;code&gt;WITH WAIT_AT_LOW_PRIORITY&lt;/code&gt;, which keeps the shrink from holding those queries up. The syntax allows it alongside &lt;code&gt;EMPTYFILE&lt;/code&gt;, though the documentation&apos;s examples only show it with a regular shrink.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this demo we already set the database to SIMPLE. If you&apos;re starting from a restore in FULL, this is roughly what I’d do:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER DATABASE DemoMergeFiles SET RECOVERY SIMPLE;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Switching to SIMPLE doesn&apos;t make &lt;code&gt;EMPTYFILE&lt;/code&gt; any lighter. Shrink isn&apos;t on Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/logs/the-transaction-log-sql-server#operations-that-can-be-minimally-logged&quot;&gt;list of operations that can be minimally logged&lt;/a&gt;, so every page move is fully logged whatever the recovery model. What SIMPLE changes is when that log space can be reused. The log truncates &quot;under the simple recovery model, after a checkpoint&quot; instead of waiting on a log backup, which is the difference between my 20GB-to-200GB log in FULL and a log that can clear itself as it goes. The shrink documentation calls it &quot;a long-running and resource-intensive operation,&quot; so in production I tell stakeholders that before I start. The database stays online, but &lt;code&gt;EMPTYFILE&lt;/code&gt; takes a significant amount of time.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Move Pages With EMPTYFILE&lt;/h2&gt;
&lt;p&gt;To collapse files within a filegroup, you move all the pages out of the files you&apos;re removing and into the ones you&apos;re keeping.&lt;/p&gt;
&lt;p&gt;The core commands for a single file look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USE DemoMergeFiles;
GO

DBCC SHRINKFILE (N&apos;DemoMergeFiles_NDF3&apos;, EMPTYFILE);
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/Screenshot-2025-12-02-200112.BdC7zqXN_Z1JtHfB.webp&quot; alt=&quot;SQL Server Management Studio showing the successful completion of the DBCC SHRINKFILE command with EMPTYFILE option&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Once the file is empty, removing it is one more command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER DATABASE DemoMergeFiles
REMOVE FILE DemoMergeFiles_NDF3;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/Screenshot-2025-12-02-200136.gQNimuxs_Z2bINFe.webp&quot; alt=&quot;SQL Server Management Studio showing the successful completion of the ALTER DATABASE REMOVE FILE command&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Behind the scenes, &lt;code&gt;EMPTYFILE&lt;/code&gt; walks the file page-by-page, moving allocations into other files in the same filegroup. The &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-shrinkfile-transact-sql&quot;&gt;documentation&lt;/a&gt; says it &quot;migrates all data from the specified file to other files in the &lt;em&gt;same filegroup&lt;/em&gt;,&quot; and that filegroup restriction is why this collapses files inside a filegroup and can&apos;t be used to move data between them. It&apos;s fully logged, and it&apos;s single-threaded, which the shrink documentation doesn&apos;t mention either way.&lt;/p&gt;
&lt;p&gt;The same page has a detail that matters if you stop one partway, like I did with the 400GB database: &quot;If you use the &lt;code&gt;EMPTYFILE&lt;/code&gt; parameter and cancel the operation, the file isn&apos;t marked to prevent additional data from being added.&quot; That marking is what keeps new data out of a file while it&apos;s being emptied. The pages that already moved stay moved, but after a cancel the file can take new writes again, so it&apos;s worth running &lt;code&gt;EMPTYFILE&lt;/code&gt; again before trying to remove it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Monitoring Data Page Migration Progress&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;EMPTYFILE&lt;/code&gt; is slow and says very little in the Messages tab while it runs, so I watch it from &lt;code&gt;sys.dm_exec_requests&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT  session_id,
        command,
        status,
        percent_complete,
        start_time,
        total_elapsed_time,
        wait_type,
        wait_time,
        last_wait_type,
        cpu_time,
        reads,
        writes
FROM sys.dm_exec_requests
WHERE command LIKE &apos;%DBCC%&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/Screenshot-2025-12-02-200047.DT4AoisG_ZgCwAv.webp&quot; alt=&quot;Query results from sys.dm_exec_requests showing the progress of the DBCC SHRINKFILE operation with EMPTYFILE, including percent complete and elapsed time&quot; /&gt;&lt;/p&gt;
&lt;p&gt;In the demo this finishes quickly. If you want to watch it for longer, increase &lt;code&gt;@TargetRows&lt;/code&gt; in the data load.&lt;/p&gt;
&lt;p&gt;In production, a ~220 GB NDF took about three hours. It runs online, but I still schedule it in a maintenance window because of the I/O it generates.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Remove The Emptied File&lt;/h2&gt;
&lt;p&gt;Once &lt;code&gt;EMPTYFILE&lt;/code&gt; finishes, the file has no allocated pages and can be removed. After removing it, check the layout again:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT  name,
        type_desc,
        physical_name,
        size * 8 / 1024 AS SizeMB,
        FILEPROPERTY(name, &apos;SpaceUsed&apos;) * 8 / 1024 AS UsedMB
FROM sys.database_files;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/Screenshot-2025-12-02-200449.CpQrAdzL_Zpg88w.webp&quot; alt=&quot;Query results showing the updated file layout after removing NDF files, now displaying only the remaining data files with their size and space used&quot; /&gt;&lt;/p&gt;
&lt;p&gt;In production I repeat the &quot;EMPTYFILE + REMOVE FILE&quot; sequence for each secondary file I&apos;m removing.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Post-Migration Validation&lt;/h2&gt;
&lt;p&gt;Once the files are down to one, I go back over a few things before I call it done:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check the file layout again.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;DBCC CHECKDB&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Take a fresh full backup if you have time.&lt;/li&gt;
&lt;li&gt;Switch recovery model back to whatever it should be.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;-- Sanity check
DBCC CHECKDB (N&apos;DemoMergeFiles&apos;) WITH NO_INFOMSGS;
GO

-- Reset database back to FULL
ALTER DATABASE DemoMergeFiles SET RECOVERY FULL;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Production Environment Considerations&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Pre-growing the destination files doesn&apos;t make &lt;code&gt;EMPTYFILE&lt;/code&gt; any faster. It avoids autogrowth events during the move, which is worth doing, but the page migration is still single-threaded and still fully logged.&lt;/li&gt;
&lt;li&gt;FULL recovery is what caused the log growth I opened with. If you can&apos;t switch to SIMPLE, frequent log backups during the operation are the alternative, and you need somewhere to put them.&lt;/li&gt;
&lt;li&gt;Consolidating files isn&apos;t a performance fix. The benefit is less administrative overhead, with one file to think about instead of eight during a restore.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;When Multiple Data Files Are Still Worth It&lt;/h2&gt;
&lt;p&gt;I wouldn&apos;t do this to every database with &lt;code&gt;.ndf&lt;/code&gt; files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TempDB is the usual exception. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/sql-server/install/instance-configuration&quot;&gt;setup guidance&lt;/a&gt; defaults it to one data file per logical processor, up to eight, and suggests adding more in multiples of four if contention shows up.&lt;/li&gt;
&lt;li&gt;Very large databases spanning multiple volumes might still &lt;em&gt;need&lt;/em&gt; multiple files per filegroup.&lt;/li&gt;
&lt;li&gt;Partition schemes and archival strategies sometimes lean on separate files for a reason.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;The move takes a long time, but it only happens once, and afterwards there&apos;s one data file instead of eight. That matters most on the day someone has to restore it somewhere else.&lt;/p&gt;
&lt;p&gt;None of this makes the server faster tomorrow. It reduces operational complexity. If you want to show the process to your team or capture screenshots for documentation, the &lt;code&gt;DemoMergeFiles&lt;/code&gt; database above runs the whole thing end to end without touching production.&lt;/p&gt;
</content:encoded></item><item><title>Automating sp_WhoIsActive Installation</title><link>https://chandlergray.com/blog/automating-spwhoisactive-installation/</link><guid isPermaLink="true">https://chandlergray.com/blog/automating-spwhoisactive-installation/</guid><description>Install sp_WhoIsActive across your SQL Server instances with a single dbatools command instead of running the script by hand.</description><pubDate>Mon, 17 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I use &amp;lt;a href=&quot;https://github.com/amachanic/sp_whoisactive/releases&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;sp_WhoIsActive&amp;lt;/a&amp;gt; on pretty much every SQL Server I work with. Installing it by hand works fine. The project&apos;s README says to download &lt;code&gt;sp_WhoIsActive.sql&lt;/code&gt;, open it in SSMS and run it. Now that dbatools can do that for me, the manual route feels like an extra step. If you don&apos;t have dbatools installed yet, I keep a short reference here: &amp;lt;a href=&quot;https://chandlergray.com/blog/installing-dbatools/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;Installing dbatools&amp;lt;/a&amp;gt;.&lt;/p&gt;
&lt;p&gt;With dbatools available, the install is two lines:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$instance = Connect-DbaInstance -SqlInstance &quot;localhost\MSSQLSERVER&quot; -TrustServerCertificate
Install-DbaWhoIsActive -SqlInstance $instance -Database master -Verbose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/dbatools-spwhoisactive-install.BGDewZIO_135L5b.webp&quot; alt=&quot;sp_WhoIsActive Results&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Install-DbaWhoIsActive&lt;/code&gt; downloads the latest release from GitHub each time it runs, unless you point it at a local copy of the file. The &amp;lt;a href=&quot;https://dbatools.io/Install-DbaWhoIsActive&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;command&apos;s documentation&amp;lt;/a&amp;gt; covers both options.&lt;/p&gt;
&lt;p&gt;The same page explains why &lt;code&gt;-Database master&lt;/code&gt; is written out. In an interactive session the command defaults to master on its own, but in a script it would stop and prompt for a database, so the parameter is required there. Master is also the right place for it. Installed there, the procedure can be run from every database on the instance, and installed in a user database it only works in that one. The sp_WhoIsActive README gives the same advice.&lt;/p&gt;
&lt;p&gt;When I need it on several servers, I loop through the list:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$servers = @(
  &quot;prod1\SQL&quot;
  ,&quot;prod2\SQL&quot;
  ,&quot;dev\SQL&quot;
)

foreach ($s in $servers) {
  $i = Connect-DbaInstance -SqlInstance $s -TrustServerCertificate
  Install-DbaWhoIsActive -SqlInstance $i -Database master -Verbose
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It saves a few minutes and keeps the install the same across the environments I touch.&lt;/p&gt;
&lt;p&gt;The loop has no error handling. For three servers the output is easy enough to watch as it runs. For a longer list, I&apos;d wrap the body of the loop in a &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt; and collect the servers that failed, so the list of what still needs attention is waiting at the end.&lt;/p&gt;
</content:encoded></item><item><title>Don&apos;t Outsource Your Problem</title><link>https://chandlergray.com/blog/dont-outsource-your-problem/</link><guid isPermaLink="true">https://chandlergray.com/blog/dont-outsource-your-problem/</guid><description>How to ask for technical help without handing the whole problem to someone else, and what to try before you send that message.</description><pubDate>Fri, 31 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I&apos;ve fired off plenty of screenshots hoping someone else would save me five minutes of reading, and it never helped. You hit an error, take one look at the message, and drop it in Teams with a quick &quot;anyone seen this before?&quot; hoping someone will come back with an answer.&lt;/p&gt;
&lt;p&gt;That&apos;s what I call &lt;em&gt;outsourcing your problem&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Asking for help is fine. It&apos;s part of working in tech, but how you ask matters. When you just forward the problem, you&apos;re asking someone else to start cold. They don&apos;t know what you&apos;re doing, what changed, or what you&apos;ve already checked, so they have to do all the discovery you skipped.&lt;/p&gt;
&lt;p&gt;If you want real help, bring context. Read the message and try to understand what it&apos;s saying. Even if you don&apos;t get far, it shows the other person where to start. Compare these two:&lt;/p&gt;
&lt;p&gt;&quot;Anyone know how to fix this?&quot;
vs.
&quot;I ran X and got this error. It looks like Y is failing, but I&apos;m not sure why. Can you help me read it?&quot;&lt;/p&gt;
&lt;p&gt;The second one tells people you&apos;ve tried to think it through. They&apos;ll respond faster, and you&apos;ll learn something instead of just copying a fix.&lt;/p&gt;
&lt;p&gt;Most people don&apos;t expect you to know everything, but they do expect you to care enough to figure out what&apos;s happening before asking for a rescue. So before you hit send on that screenshot, read the error, think about what changed, and try to narrow it down. Then, if you&apos;re still stuck, ask for help &lt;em&gt;with context&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;This doesn&apos;t mean working everything out alone. If it&apos;s a common error with a common fix, and I can find the answer in the archives by myself, I&apos;ll take it. The case where I try to stay with it is when I barely understand what I&apos;m trying to build in the first place.&lt;/p&gt;
&lt;p&gt;That happened when I installed Proxmox for the first time. The storage pool I set up was only recognizing some of the space on my drives, and I didn&apos;t notice until I was moving my media library onto a VM and started getting errors saying I was out of drive space. I spent the first few hours learning how to troubleshoot drives on Linux, mostly reading Stack Overflow and Reddit posts, trying to figure out how to even figure out what was happening. It came down to how ZFS handles drives of different sizes, which was a gap in my understanding and not something wrong with Proxmox.&lt;/p&gt;
&lt;p&gt;I could have reached out to someone I knew who probably would have steered me in the right direction sooner. I didn&apos;t, because I barely understood what I was trying to accomplish, and I wanted to use it to fill in that gap. I also didn&apos;t want them to feel like they needed to take responsibility for my problem. That second reason is the part of this I care about most. Handing someone a screenshot and nothing else hands them the problem too.&lt;/p&gt;
&lt;p&gt;The best fixes I&apos;ve learned came from slowing down long enough to understand what the system was actually saying.&lt;/p&gt;
</content:encoded></item><item><title>Installing dbatools</title><link>https://chandlergray.com/blog/installing-dbatools/</link><guid isPermaLink="true">https://chandlergray.com/blog/installing-dbatools/</guid><description>A quick reference for installing the dbatools PowerShell module, covering the PowerShell Gallery install and offline options.</description><pubDate>Wed, 15 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I write a lot about SQL Server automation, and dbatools comes up constantly. Instead of rewriting the install steps in every post, I&apos;m putting them here once so I can point back to them.&lt;/p&gt;
&lt;p&gt;The full instructions live on the official site at &amp;lt;a href=&quot;https://dbatools.io/install&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;dbatools.io/install&amp;lt;/a&amp;gt;. If you&apos;re new to dbatools, follow those first, since they&apos;re maintained by the project and this page is only my summary of them. Their &amp;lt;a href=&quot;https://dbatools.io/getting-started/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;Getting Started page&amp;lt;/a&amp;gt; is worth a look while you&apos;re there. It&apos;s mostly examples, which is a good way to see what the module can do.&lt;/p&gt;
&lt;p&gt;The install the dbatools site recommends is one line, and it doesn&apos;t need an admin session:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Install-Module dbatools -Scope CurrentUser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-Scope CurrentUser&lt;/code&gt; puts the module in a folder under your own profile, and Microsoft&apos;s &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/powershell/module/powershellget/install-module&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;Install-Module documentation&amp;lt;/a&amp;gt; describes that location as &quot;accessible only to the current user of the computer.&quot; One side effect follows from that. A scheduled task or SQL Agent job running under a different account, like a service account, won&apos;t find a module you installed this way, and it needs its own copy or an install for all users.&lt;/p&gt;
&lt;p&gt;Installing for all users is &lt;code&gt;Install-Module dbatools&lt;/code&gt; from an administrator PowerShell session. Leaving &lt;code&gt;-Scope&lt;/code&gt; off doesn&apos;t always mean all users, though. The same documentation says the default depends on the PowerShellGet version. Version 1.x defaults to all users, while 2.0 and later on PowerShell 6 or higher default to the current user unless the session is elevated. Writing the scope out avoids having to know which one you&apos;re on.&lt;/p&gt;
&lt;p&gt;If you&apos;re prompted about trusting the repository, say yes, since it&apos;s the official source. On older systems you may also need to trust PSGallery explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To check that it worked:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-Command -Module dbatools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And to pick up a newer release later:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Update-Module dbatools
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you get errors about script execution policy, you may need to let PowerShell run the module&apos;s scripts. The simplest way is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Microsoft&apos;s &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;execution policy documentation&amp;lt;/a&amp;gt; explains the setting. &lt;code&gt;RemoteSigned&lt;/code&gt; runs scripts written on the local machine and requires a trusted signature on scripts downloaded from the internet. The same page points out that execution policy &quot;isn&apos;t a security boundary,&quot; so it&apos;s there to stop accidents rather than attacks.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That covers most modern setups. For machines without internet access, the dbatools site has an offline install guide, and it also lists Chocolatey (&lt;code&gt;choco install dbatools&lt;/code&gt;) as another option.&lt;/p&gt;
</content:encoded></item><item><title>Setting Up an FTP Server on Ubuntu with vsftpd</title><link>https://chandlergray.com/blog/setting-up-an-ftp-server-on-ubuntu-with-vsftpd/</link><guid isPermaLink="true">https://chandlergray.com/blog/setting-up-an-ftp-server-on-ubuntu-with-vsftpd/</guid><description>Step-by-step guide to setting up an FTP server on Ubuntu using vsftpd, from VM creation to testing connections on your local network.</description><pubDate>Fri, 26 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I run my servers inside Proxmox, so in this case the FTP service lives on an Ubuntu Server VM there. The same steps apply whether you&apos;re on bare metal or any other hypervisor.&lt;/p&gt;
&lt;p&gt;This sets up plain FTP, which sends the username, the password and the file contents across the network unencrypted. On a home LAN behind a router, that&apos;s a decision you can make for yourself, but not on anything exposed to the internet. The &lt;a href=&quot;https://manpages.debian.org/bookworm/vsftpd/vsftpd.conf.5.en.html&quot;&gt;&lt;code&gt;vsftpd.conf&lt;/code&gt; man page&lt;/a&gt; has an &lt;code&gt;ssl_enable&lt;/code&gt; option that encrypts both the login and the data connections, or you can use SFTP over SSH instead of FTP.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Create the VM&lt;/h2&gt;
&lt;p&gt;Install Ubuntu Server LTS and give it a static LAN IP so it’s easy to reach later. I used a bridged adapter in Proxmox so the VM behaves like any other machine on my network.&lt;/p&gt;
&lt;p&gt;If the VM can’t be reached from another machine, double-check the network adapter mode and make sure the IP address is in the right subnet.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Update the OS&lt;/h2&gt;
&lt;p&gt;Bring the system up to date before adding anything else.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mirror issues can cause failures. Running the command again usually clears it up.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Install vsftpd&lt;/h2&gt;
&lt;p&gt;Install the FTP daemon package.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt install vsftpd -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the service doesn’t appear to exist, confirm with &lt;code&gt;systemctl status vsftpd&lt;/code&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Create an FTP User&lt;/h2&gt;
&lt;p&gt;I added a dedicated account just for FTP traffic so it’s separate from system logins.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo adduser ftpuser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set a password when prompted.&lt;/p&gt;
&lt;p&gt;Later login failures (&lt;code&gt;530 Login incorrect&lt;/code&gt;) often mean the account is disabled in &lt;code&gt;/etc/ftpusers&lt;/code&gt; or &lt;code&gt;local_enable&lt;/code&gt; isn’t set in the config.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Set Up the Directory Structure&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;vsftpd&lt;/code&gt; refuses to start a session where the chroot directory itself is writable by the user. To work around this, I made a locked root folder with a writable subdirectory for uploads.&lt;/p&gt;
&lt;p&gt;The man page doesn&apos;t document that check, so the error message is the best guide to it. It does have a warning on &lt;code&gt;chroot_local_user&lt;/code&gt;, which is the setting that jails users. It says the option &quot;has security implications, especially if the users have upload permission, or shell access.&quot; Keeping the root locked and the writable area down to one directory is one way to limit that.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /srv/ftp/uploads
sudo chown root:root /srv/ftp
sudo chmod 755 /srv/ftp
sudo chown ftpuser:ftpuser /srv/ftp/uploads
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Common errors:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;500 OOPS: vsftpd: refusing to run with writable root inside chroot&lt;/code&gt; usually means permissions on &lt;code&gt;/srv/ftp&lt;/code&gt; are wrong. It must be owned by root and not writable.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Configure vsftpd&lt;/h2&gt;
&lt;p&gt;Open the config:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nano /etc/vsftpd.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here’s what I used:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;listen=YES
listen_ipv6=NO

anonymous_enable=NO
local_enable=YES
write_enable=YES
chroot_local_user=YES
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That config sets up the account and the chroot but doesn&apos;t pin the passive port range, so vsftpd picks from a wide default and the firewall rules further down won&apos;t line up with it. The man page gives the default for both &lt;code&gt;pasv_min_port&lt;/code&gt; and &lt;code&gt;pasv_max_port&lt;/code&gt; as &quot;0 (use any port),&quot; and says a range &quot;can be used to specify a narrow port range to assist firewalling,&quot; which is the reason to set them at all. Add these two so the range matches what you open in UFW:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pasv_min_port=30000
pasv_max_port=30049
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The man page says &lt;code&gt;chroot_local_user=YES&lt;/code&gt; places local users &quot;in a chroot() jail in their home directory after login.&quot; &lt;code&gt;adduser&lt;/code&gt; gives &lt;code&gt;ftpuser&lt;/code&gt; a home directory under &lt;code&gt;/home&lt;/code&gt;, so to use the &lt;code&gt;/srv/ftp&lt;/code&gt; layout above, point the user at it. The &lt;code&gt;local_root&lt;/code&gt; option sets the directory vsftpd changes into after a local login:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;local_root=/srv/ftp
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;If directory listings stall, the passive port range isn’t open.&lt;/li&gt;
&lt;li&gt;If the server advertises &lt;code&gt;127.0.1.1&lt;/code&gt; in passive mode, add &lt;code&gt;pasv_address=&amp;lt;your_LAN_IP&amp;gt;&lt;/code&gt; to the config.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Restart and Enable the Service&lt;/h2&gt;
&lt;p&gt;Restart the daemon and enable it at boot.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl restart vsftpd
sudo systemctl enable vsftpd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it fails to enable, check the logs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;journalctl -u vsftpd -n 50 --no-pager
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Adjust the Firewall&lt;/h2&gt;
&lt;p&gt;If UFW is enabled, open the command port and the passive range.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo ufw allow 21/tcp
sudo ufw allow 30000:30049/tcp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;UFW must be active for these rules to matter. Verify with &lt;code&gt;sudo ufw status&lt;/code&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Test Locally&lt;/h2&gt;
&lt;p&gt;Before trying a client, I tested from the server itself.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -v --user &apos;ftpuser:YourPassword&apos; ftp://127.0.0.1/
curl -v --user &apos;ftpuser:YourPassword&apos; -T /etc/hosts ftp://127.0.0.1/uploads/
ls -l /srv/ftp/uploads
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;530 Login incorrect&lt;/code&gt;: check username, password, and config.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;550 Permission denied&lt;/code&gt;: verify &lt;code&gt;/srv/ftp/uploads&lt;/code&gt; is owned by &lt;code&gt;ftpuser&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Connect from Another Machine&lt;/h2&gt;
&lt;p&gt;Connect with an FTP client using your VM’s IP address.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Host: &lt;code&gt;&amp;lt;LAN_IP&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Port: 21&lt;/li&gt;
&lt;li&gt;Protocol: FTP&lt;/li&gt;
&lt;li&gt;Username: &lt;code&gt;ftpuser&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Password: your password&lt;/li&gt;
&lt;li&gt;Transfer mode: Passive&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I only use these FTP servers on my local network, so I haven&apos;t gone overboard with hardening them for internet exposure. Network security isn&apos;t my specialty, so I stick to the basics. If you&apos;ve got suggestions for tightening things up, either for FTP specifically or my home lab in general, I&apos;d be glad to hear them.&lt;/p&gt;
</content:encoded></item><item><title>The Monty Hall Problem That Made Me a Better DBA</title><link>https://chandlergray.com/blog/the-monty-hall-problem-that-made-me-a-better-dba/</link><guid isPermaLink="true">https://chandlergray.com/blog/the-monty-hall-problem-that-made-me-a-better-dba/</guid><description>A parameter sniffing demo in SQL Server, and why the Monty Hall problem comes to mind when I look at a cached execution plan.</description><pubDate>Tue, 22 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import Callout from &quot;@/components/Callout.astro&quot;;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Switching doors in the Monty Hall problem wins two games out of three, and staying wins one. I know that&apos;s the answer and it still doesn&apos;t feel right to me. It&apos;s also what I think of when I&apos;m looking at an execution plan that SQL Server compiled for somebody else&apos;s parameter.&lt;/p&gt;
&lt;h2&gt;The game&lt;/h2&gt;
&lt;p&gt;You&apos;re on a game show with three doors. There&apos;s a car behind one of them and a goat behind each of the other two. You pick a door, and the host, Monty, opens one of the two you didn&apos;t pick to show you a goat. Then he asks whether you want to keep your door or switch to the other closed one.&lt;/p&gt;
&lt;p&gt;With two doors left it looks like a coin flip. What makes it different is that Monty knows where the car is and never opens that door. &lt;a href=&quot;https://en.wikipedia.org/wiki/Monty_Hall_problem&quot;&gt;Wikipedia&apos;s article on the problem&lt;/a&gt; lists the rules the usual answer depends on, including that the host &quot;must always open a door to reveal a goat and never the car.&quot; Your first pick was right one time in three, and nothing Monty does afterwards changes that, so the other two thirds are sitting behind the door he left closed.&lt;/p&gt;
&lt;p&gt;If you&apos;d rather see it than follow the reasoning, this plays the game 200,000 times:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import random

N = 200_000
stay = switch = 0
for _ in range(N):
    car = random.randrange(3)
    pick = random.randrange(3)
    opened = random.choice([d for d in range(3) if d != pick and d != car])
    other = [d for d in range(3) if d != pick and d != opened][0]
    stay += pick == car
    switch += other == car

print(f&quot;stay {stay / N:.4f}  switch {switch / N:.4f}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It prints numbers close to 0.33 for staying and 0.67 for switching. MythBusters tested it too, in a 2011 episode called &quot;Wheel of Mythfortune.&quot;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/math-lady-meme.CB9JFPAu_1ztEvv.webp&quot; alt=&quot;Woman confused math equations&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Where SQL Server comes in&lt;/h2&gt;
&lt;p&gt;The first time a stored procedure runs, SQL Server compiles a plan for it using the parameter values from that call, then caches the plan and reuses it for later calls. Microsoft&apos;s page on &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/stored-procedures/recompile-a-stored-procedure&quot;&gt;recompiling a stored procedure&lt;/a&gt; puts it this way: &quot;any parameter values that are used by the procedure when it compiles are included as part of generating the query plan.&quot; This is what people mean by parameter sniffing. If the first value is a typical one, the plan suits most of the calls that follow. If it isn&apos;t, every call after it runs on a plan built for a value it doesn&apos;t have.&lt;/p&gt;
&lt;p&gt;That&apos;s where the game comes to mind for me. A choice gets made with the information available at the time, and the question is whether to revisit it once you know more. By default SQL Server doesn&apos;t revisit it. The cached plan stays until something causes a recompile. The comparison is loose, since there&apos;s no host and none of the probability carries over, but &quot;should I switch now that I know more&quot; is the question I end up asking about a cached plan.&lt;/p&gt;
&lt;h2&gt;The demo&lt;/h2&gt;
&lt;p&gt;The setup builds a table where one value makes up almost every row. Run it on a test instance, because the steps after it clear the plan cache.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Setup
CREATE DATABASE MontyDB
GO

DROP TABLE IF EXISTS dbo.MontyTest;

CREATE TABLE dbo.MontyTest (
	Id INT IDENTITY PRIMARY KEY
	,Category VARCHAR(20)
	,SomeData CHAR(100)
	);

-- Insert skewed data: 1 very common value, 2 rare values
INSERT INTO dbo.MontyTest (
	Category
	,SomeData
	)
SELECT CASE 
		WHEN n &amp;lt;= 1000
			THEN &apos;RareA&apos;
		WHEN n &amp;lt;= 2000
			THEN &apos;RareB&apos;
		ELSE &apos;Common&apos;
		END
	,REPLICATE(&apos;x&apos;, 100)
FROM (
	SELECT TOP (100000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
	FROM sys.all_objects a
	CROSS JOIN sys.all_objects b
	) AS x;
-- (100000 rows affected)

-- Create index to make plan choices interesting
CREATE NONCLUSTERED INDEX IX_Monty_Category ON dbo.MontyTest (Category);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s 1,000 rows of &apos;RareA&apos;, 1,000 of &apos;RareB&apos; and 98,000 of &apos;Common&apos;, so &apos;Common&apos; is 98% of the table.&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;warning&quot;&amp;gt;
&lt;code&gt;DBCC FREEPROCCACHE&lt;/code&gt; with no arguments clears every plan on the instance, not only the ones in this database. The &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-freeproccache-transact-sql&quot;&gt;documentation&lt;/a&gt; says it &quot;causes all plans to be evicted,&quot; which &quot;can cause a sudden, temporary decrease in query performance as the number of new compilations increases.&quot; On SQL Server 2016 and later, &lt;code&gt;ALTER DATABASE SCOPED CONFIGURATION CLEAR PROCEDURE_CACHE&lt;/code&gt; clears the plans for the current database only.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;h3&gt;Step 1: Create a stored procedure to trigger plan reuse&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;-- Clean cache and create procedure
DBCC FREEPROCCACHE;
GO

DROP PROCEDURE IF EXISTS dbo.SniffMonty;
GO
CREATE PROCEDURE dbo.SniffMonty @Category VARCHAR(20)
AS
BEGIN
    SET NOCOUNT ON;
    SELECT * FROM dbo.MontyTest WHERE Category = @Category;
END;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 2: Run it with the rare value first&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;-- This execution generates the cached plan
EXEC dbo.SniffMonty @Category = &apos;RareA&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That call compiles a plan for &apos;RareA&apos; and caches it. Running the procedure again with &apos;Common&apos; reuses that plan.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Run with common value, reusing the plan compiled for RareA
EXEC dbo.SniffMonty @Category = &apos;Common&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2025-07-22-144325.0y71bfuF_Z2oaEs.webp&quot; alt=&quot;Execution plan for EXEC dbo.SniffMonty @Category = &apos;Common&apos; reusing the plan cached for RareA, showing a Clustered Index Scan with 98000 of 1000 rows (9800 percent)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The numbers under the operator, &lt;code&gt;98000 of 1000 (9800%)&lt;/code&gt;, are the actual row count over the estimate. Hovering over the operator shows the same thing in more detail, with 1000 for &quot;Estimated Number of Rows Per Execution&quot; and 98000 for &quot;Actual Number of Rows for All Executions.&quot;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2025-07-22-144342.B-SyPeCw_bErBc.webp&quot; alt=&quot;Operator tooltip for the Clustered Index Scan showing Estimated Number of Rows for All Executions 1000 against Actual Number of Rows for All Executions 98000&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The estimate of 1,000 came from &apos;RareA&apos;, which is the value the plan was compiled for. The call that reused it returned 98 times as many rows as the plan expected.&lt;/p&gt;
&lt;h3&gt;Step 3: Clear the cache and reverse the order&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;-- Clear cache
DBCC FREEPROCCACHE;
GO

-- Now run with the common value first
EXEC dbo.SniffMonty @Category = &apos;Common&apos;;
-- Then rare
EXEC dbo.SniffMonty @Category = &apos;RareA&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2025-07-22-144817.DBwoRItV_ZnOXE1.webp&quot; alt=&quot;Both queries in one batch after clearing the cache. Query 1 for Common reads 98000 of 98000 rows at 100 percent, and Query 2 for RareA reads 1000 of 98000 rows at 1 percent, both using the same Clustered Index Scan&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Both calls ran in one batch, so the screenshot has both plans. &apos;Common&apos; went first and got &lt;code&gt;98000 of 98000 (100%)&lt;/code&gt;, which is what it looks like when a plan runs with the value it was compiled for. &apos;RareA&apos; reused that plan and got &lt;code&gt;1000 of 98000 (1%)&lt;/code&gt;. The estimate is now 98 times too high where it was 98 times too low before.&lt;/p&gt;
&lt;h2&gt;What the plans actually show&lt;/h2&gt;
&lt;p&gt;The setup creates an index on &lt;code&gt;Category&lt;/code&gt; so the optimizer has a choice to make, but it didn&apos;t use that index in any of these runs. Every plan above is the same Clustered Index Scan, which reads the whole table, and the tooltip shows it reading all 100,000 rows. The estimate changed between runs and the plan stayed the same.&lt;/p&gt;
&lt;p&gt;The likely reason is in the green text at the top of each plan. SQL Server suggests a missing index that includes &lt;code&gt;SomeData&lt;/code&gt;. The procedure selects every column, and &lt;code&gt;IX_Monty_Category&lt;/code&gt; only has &lt;code&gt;Category&lt;/code&gt;, so using it would mean a trip back to the table for the rest of each row. That&apos;s the key lookup I wrote about in &lt;a href=&quot;https://chandlergray.com/blog/how-to-improve-sql-server-performance-by-avoiding-key-lookups/&quot;&gt;avoiding key lookups&lt;/a&gt;. For this table, SQL Server estimated that scanning was cheaper even when it expected only 1,000 rows. Confirming it would mean rerunning the demo with fewer columns selected, or with the suggested index in place, and checking whether the two orders then produce different plans.&lt;/p&gt;
&lt;p&gt;So the demo shows the estimate going stale, which is the core of parameter sniffing, but it doesn&apos;t show a stale plan costing anything. The elapsed times are all small: 0.023s for &apos;Common&apos; on the plan compiled for &apos;RareA&apos;, 0.033s for &apos;Common&apos; on its own plan, and 0.006s for &apos;RareA&apos; on the plan compiled for &apos;Common&apos;. The trouble starts when the estimate decides between two different plans. Microsoft describes that case as one where &quot;a single cached plan for a parameterized query isn&apos;t optimal for all possible incoming parameter values.&quot;&lt;/p&gt;
&lt;h2&gt;Ways to switch doors&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Recompile.&lt;/strong&gt; &lt;code&gt;EXEC ... WITH RECOMPILE&lt;/code&gt; compiles a plan for the values in that call. The statement-level version, &lt;code&gt;OPTION (RECOMPILE)&lt;/code&gt; inside the procedure, tells SQL Server to &quot;generate a new, temporary plan for the query and immediately discard that plan after the query completes execution,&quot; according to the &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query&quot;&gt;query hints documentation&lt;/a&gt;. Every call pays for a compile that way, which is worth measuring before using it on a procedure that runs constantly. In this demo, recompiling would give each call an accurate estimate, but going by the screenshots it would still be a scan.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Force recompile per-execution
EXEC dbo.SniffMonty @Category = &apos;Common&apos; WITH RECOMPILE;
EXEC dbo.SniffMonty @Category = &apos;RareA&apos; WITH RECOMPILE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;OPTIMIZE FOR UNKNOWN&lt;/code&gt;.&lt;/strong&gt; The same page says this hint makes the optimizer &quot;use the average selectivity of the predicate across all column values, instead of using the runtime parameter value.&quot; With three distinct values in this table, I&apos;d expect that to come out around a third of the rows, which is wrong for all three of them, though the demo doesn&apos;t show the estimate it actually produces.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;OPTIMIZE FOR (@Category = &apos;Common&apos;)&lt;/code&gt;.&lt;/strong&gt; This compiles the plan as if every call passed &apos;Common&apos;. That suits a table like this one, where almost every call probably will, and it puts &apos;RareA&apos; where it was in Step 3.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Parameter Sensitive Plan optimization.&lt;/strong&gt; On SQL Server 2022 and later, &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/performance/parameter-sensitive-plan-optimization&quot;&gt;PSP optimization&lt;/a&gt; &quot;automatically enables multiple, active cached plans for a single parameterized statement,&quot; chosen by the parameter value at runtime. It needs database compatibility level 160, it &quot;currently only works with equality predicates,&quot; which &lt;code&gt;Category = @Category&lt;/code&gt; is, and it doesn&apos;t run on queries with a &lt;code&gt;RECOMPILE&lt;/code&gt; hint. The screenshots don&apos;t show MontyDB&apos;s compatibility level, so it isn&apos;t clear whether PSP was available when they were taken.&lt;/p&gt;
&lt;h2&gt;What this doesn&apos;t answer yet&lt;/h2&gt;
&lt;p&gt;Two things are still open. The first is a version of this demo where the index really is the better choice for &apos;RareA&apos;, so the stale estimate leads to a different plan instead of the same one. The second is running it at compatibility level 160 to see whether PSP splits the procedure into separate plans for &apos;RareA&apos; and &apos;Common&apos;.&lt;/p&gt;
</content:encoded></item><item><title>SELECT * in SQL Server: Payload and Throughput</title><link>https://chandlergray.com/blog/stop-using-select-star/</link><guid isPermaLink="true">https://chandlergray.com/blog/stop-using-select-star/</guid><description>A practical SQL Server benchmark showing how returning more columns changes payload size and affects throughput.</description><pubDate>Wed, 09 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In this test, &lt;code&gt;SELECT *&lt;/code&gt; had an estimated row size of 2,965 bytes, while the four columns the application actually uses came to 342, about 8.7 times smaller.&lt;/p&gt;
&lt;p&gt;I wanted to see what changed in my environment when I used &lt;code&gt;SELECT *&lt;/code&gt; instead of listing only the columns my application needed, so I ran the same single-customer lookup three ways against a &lt;code&gt;CustomerProfile&lt;/code&gt; table with 500,000 rows. The three forms were a narrow application payload (&lt;code&gt;CustomerID, Email, LastLogin, Status&lt;/code&gt;), an explicit full payload, and &lt;code&gt;SELECT *&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;From plan details, all three versions used the same lookup method in this run. The main difference was the size of the payload returned to the application:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query shape&lt;/th&gt;
&lt;th&gt;Estimated row size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Narrow projection&lt;/td&gt;
&lt;td&gt;342 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explicit full projection&lt;/td&gt;
&lt;td&gt;2965 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT *&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2965 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;In this run, &lt;code&gt;SELECT *&lt;/code&gt; returned a payload about &lt;strong&gt;8.7x wider&lt;/strong&gt; than the narrow query.&lt;/p&gt;
&lt;p&gt;Plan property captures from the run:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/2026-02-15%2016_22_46-SQLQuery5.sql%20-%20localhost.SelectStarEvaluationDemo%20(DEV-SQLSERVER20_chandlergray.CymQwh4-_Z1Q3ISh.webp&quot; alt=&quot;Execution plan properties for narrow projection (estimated row size 342 B)&quot; /&gt;
&lt;img src=&quot;https://chandlergray.com/_astro/2026-02-15%2016_23_13-SQLQuery5.sql%20-%20localhost.SelectStarEvaluationDemo%20(DEV-SQLSERVER20_chandlergray.qH_TGEuc_Z1MhLVc.webp&quot; alt=&quot;Execution plan properties for SELECT * (estimated row size 2965 B)&quot; /&gt;
&lt;img src=&quot;https://chandlergray.com/_astro/2026-02-15%2016_23_33-SQLQuery5.sql%20-%20localhost.SelectStarEvaluationDemo%20(DEV-SQLSERVER20_chandlergray.CxF1kwx5_27zMCf.webp&quot; alt=&quot;Execution plan properties for explicit full projection (estimated row size 2965 B)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The performance details from this run are below. Query cost was read from the plan banner and CPU/elapsed came from &lt;code&gt;STATISTICS TIME&lt;/code&gt; output.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Relative query cost (single-query batch)&lt;/th&gt;
&lt;th&gt;Estimated CPU cost (plan)&lt;/th&gt;
&lt;th&gt;Observed CPU time&lt;/th&gt;
&lt;th&gt;Observed elapsed time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Narrow projection&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0.0001581&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;0-1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explicit full projection&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0.0001581&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;37 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT *&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0.0001581&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;39-40 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two notes help with interpretation. First, query cost here is relative to the current batch, so it&apos;s useful for side-by-side shape checks but not as a measure of scale by itself. Second, CPU is reported as &lt;code&gt;0 ms&lt;/code&gt; for all three queries in this run, which means CPU was below the visible resolution of this capture. Because of that, the scale math below uses payload size and elapsed time.&lt;/p&gt;
&lt;p&gt;Using the row-size delta from this run, &lt;code&gt;2965 - 342 = 2623&lt;/code&gt; extra bytes were returned per call when &lt;code&gt;SELECT *&lt;/code&gt; was used instead of the narrow query.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Executions/day&lt;/th&gt;
&lt;th&gt;Extra payload/day&lt;/th&gt;
&lt;th&gt;Extra payload/month (30d)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;262,300 bytes (~0.25 MB)&lt;/td&gt;
&lt;td&gt;~7.50 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;2,623,000 bytes (~2.50 MB)&lt;/td&gt;
&lt;td&gt;~75.06 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;262,300,000 bytes (~250.15 MB)&lt;/td&gt;
&lt;td&gt;~7.33 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;2,623,000,000 bytes (~2.44 GB)&lt;/td&gt;
&lt;td&gt;~73.29 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Using the same run&apos;s elapsed-time values also gives a straightforward throughput view:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Executions/day&lt;/th&gt;
&lt;th&gt;Narrow projection (1 ms)&lt;/th&gt;
&lt;th&gt;Explicit full projection (37 ms)&lt;/th&gt;
&lt;th&gt;&lt;code&gt;SELECT *&lt;/code&gt; (39-40 ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;0.1 sec&lt;/td&gt;
&lt;td&gt;3.7 sec&lt;/td&gt;
&lt;td&gt;3.9-4.0 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1 sec&lt;/td&gt;
&lt;td&gt;37 sec&lt;/td&gt;
&lt;td&gt;39-40 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;1m 40s&lt;/td&gt;
&lt;td&gt;61m 40s&lt;/td&gt;
&lt;td&gt;65m 0s-66m 40s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;16m 40s&lt;/td&gt;
&lt;td&gt;10h 16m 40s&lt;/td&gt;
&lt;td&gt;10h 50m 0s-11h 6m 40s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For CPU, this capture reported &lt;code&gt;0 ms&lt;/code&gt; per call for all three queries. That means CPU was below SQL Server&apos;s visible 1 ms resolution in this test. The table below shows the practical implication: measured CPU remains 0 at this capture granularity, and the only safe extrapolation is an upper bound of &lt;code&gt;&amp;lt; 1 ms&lt;/code&gt; per call.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Executions/day&lt;/th&gt;
&lt;th&gt;Measured CPU/day (from this run)&lt;/th&gt;
&lt;th&gt;Upper bound if each call were &lt;code&gt;&amp;lt; 1 ms&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.1 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 1 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 100 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 1,000 sec (~16m 40s)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This doesn&apos;t mean every &lt;code&gt;SELECT *&lt;/code&gt; query is a problem. It shows that in this scenario, wider payload has a measurable cost profile, especially as daily execution counts increase. If an application really needs the full row, &lt;code&gt;SELECT *&lt;/code&gt; can still be reasonable. If it only needs a few columns, returning everything adds overhead without adding value.&lt;/p&gt;
&lt;p&gt;If you want to check this on your own instance, run the setup and comparison queries below and inspect &lt;code&gt;Estimated Row Size&lt;/code&gt; plus the &lt;code&gt;STATISTICS TIME&lt;/code&gt; output.&lt;/p&gt;
&lt;h2&gt;Setup (500k-row demo table)&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;USE master;
GO

IF DB_ID(&apos;SelectStarEvaluationDemo&apos;) IS NULL
    CREATE DATABASE SelectStarEvaluationDemo;
GO

USE SelectStarEvaluationDemo;
GO

DROP TABLE IF EXISTS dbo.CustomerProfile;
GO

CREATE TABLE dbo.CustomerProfile
(
    CustomerID int IDENTITY(1,1) NOT NULL,
    Email nvarchar(320) NOT NULL,
    LastLogin datetime2(0) NOT NULL,
    Status char(1) NOT NULL,
    FirstName nvarchar(80) NULL,
    LastName nvarchar(80) NULL,
    Address1 nvarchar(120) NULL,
    Address2 nvarchar(120) NULL,
    City nvarchar(80) NULL,
    StateCode char(2) NULL,
    PostalCode nvarchar(20) NULL,
    CountryCode char(2) NULL,
    Phone nvarchar(30) NULL,
    MarketingOptIn bit NULL,
    BirthDate date NULL,
    LoyaltyTier tinyint NULL,
    CreditLimit money NULL,
    LastOrderAmount money NULL,
    LastOrderDate datetime2(0) NULL,
    Notes nvarchar(400) NULL,
    Preferences nvarchar(500) NULL,
    InternalFlags int NULL,
    RiskScore decimal(9,4) NULL,
    JsonProfile nvarchar(1000) NULL,
    SSN_Encrypted varbinary(256) NULL,
    CONSTRAINT PK_CustomerProfile PRIMARY KEY CLUSTERED (CustomerID)
);
GO

;WITH n AS
(
    SELECT TOP (500000)
        ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS i
    FROM sys.all_objects a
    CROSS JOIN sys.all_objects b
)
INSERT dbo.CustomerProfile
(
    Email, LastLogin, Status,
    FirstName, LastName, Address1, Address2, City, StateCode, PostalCode,
    CountryCode, Phone, MarketingOptIn, BirthDate, LoyaltyTier,
    CreditLimit, LastOrderAmount, LastOrderDate, Notes, Preferences,
    InternalFlags, RiskScore, JsonProfile, SSN_Encrypted
)
SELECT
    CONCAT(&apos;user&apos;, i, &apos;@example.com&apos;),
    DATEADD(MINUTE, -1 * (i % 100000), SYSUTCDATETIME()),
    CASE WHEN i % 7 = 0 THEN &apos;I&apos; ELSE &apos;A&apos; END,
    CONCAT(&apos;First&apos;, i),
    CONCAT(&apos;Last&apos;, i),
    CONCAT(i, &apos; Main St&apos;),
    NULL,
    CONCAT(&apos;City&apos;, i % 1000),
    RIGHT(&apos;00&apos; + CONVERT(varchar(2), (i % 50) + 1), 2),
    RIGHT(&apos;00000&apos; + CONVERT(varchar(5), i % 100000), 5),
    &apos;US&apos;,
    CONCAT(&apos;555-01&apos;, RIGHT(&apos;00&apos; + CONVERT(varchar(2), i % 100), 2)),
    CASE WHEN i % 3 = 0 THEN 1 ELSE 0 END,
    DATEADD(DAY, -1 * (i % 20000), CAST(&apos;2025-01-01&apos; AS date)),
    i % 5,
    (i % 20000) * 1.0,
    (i % 5000) * 1.0,
    DATEADD(DAY, -1 * (i % 365), SYSUTCDATETIME()),
    REPLICATE(&apos;n&apos;, 100),
    REPLICATE(&apos;p&apos;, 120),
    i % 1024,
    CAST((i % 10000) / 100.0 AS decimal(9,4)),
    CONCAT(&apos;{&quot;segment&quot;:&quot;&apos;, i % 12, &apos;&quot;,&quot;score&quot;:&apos;, i % 100, &apos;}&apos;),
    CONVERT(varbinary(256), HASHBYTES(&apos;SHA2_256&apos;, CONVERT(varchar(20), i)))
FROM n;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Queries to Compare&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SET STATISTICS IO, TIME ON;
GO

SELECT CustomerID, Email, LastLogin, Status
FROM dbo.CustomerProfile
WHERE CustomerID = 250000;
GO

SELECT *
FROM dbo.CustomerProfile
WHERE CustomerID = 250000;
GO
SELECT
    CustomerID, Email, LastLogin, Status,
    FirstName, LastName, Address1, Address2, City, StateCode, PostalCode,
    CountryCode, Phone, MarketingOptIn, BirthDate, LoyaltyTier,
    CreditLimit, LastOrderAmount, LastOrderDate, Notes, Preferences,
    InternalFlags, RiskScore, JsonProfile, SSN_Encrypted
FROM dbo.CustomerProfile
WHERE CustomerID = 250000;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For my use case, the practical takeaway is simple: match the query payload to what the application actually needs.&lt;/p&gt;
</content:encoded></item><item><title>How to Set SQL Server Autogrowth for All Databases (and Why You Should)</title><link>https://chandlergray.com/blog/how-to-set-sql-server-autogrowth-for-all-databases-and-why-you-should/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-set-sql-server-autogrowth-for-all-databases-and-why-you-should/</guid><description>Standardize autogrowth across every database with one script, and see why consistent filegrowth controls VLF fragmentation and performance.</description><pubDate>Mon, 12 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I inherit a lot of SQL Server databases. Different teams, different eras, different ideas of what &quot;good enough&quot; looks like. One thing that always surprises me is how inconsistent the autogrowth settings are. Filegrowths in megabytes, percentages, tiny increments... it&apos;s all over the place.&lt;/p&gt;
&lt;p&gt;In my earlier post on &lt;a href=&quot;https://chandlergray.com/blog/how-to-safely-shrink-a-sql-server-transaction-log-file/&quot;&gt;safely shrinking SQL Server transaction logs&lt;/a&gt;, I touched on why autogrowth matters. Each growth of the log adds more virtual log files (VLFs), and a log that grows in many small steps ends up with a lot of them, which slows down recovery and restores.&lt;/p&gt;
&lt;p&gt;I&apos;m often taking over databases that are 10+ years old, with no history, no documentation, and no log monitoring to speak of. There&apos;s nobody around to ask &lt;em&gt;why&lt;/em&gt; things are set the way they are. So, barring any evidence to the contrary, I take the same approach every time and standardize autogrowth to 1024MB for every user database.&lt;/p&gt;
&lt;h2&gt;The Script&lt;/h2&gt;
&lt;p&gt;This is the script I use:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT 
    db.name AS DatabaseName
    ,db.database_id AS DatabaseId
    ,mf.type AS FileType
    ,mf.name AS FileName
    ,&apos;USE &apos; + db.name + &apos;; ALTER DATABASE [&apos; + db.name + &apos;] MODIFY FILE (NAME = [&apos; + mf.name + &apos;], FILEGROWTH = 1024MB);&apos; AS Script
FROM sys.databases db
JOIN sys.master_files mf ON db.database_id = mf.database_id
WHERE db.database_id &amp;gt; 4;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it, get your &lt;code&gt;ALTER DATABASE&lt;/code&gt; commands, and apply them. This doesn&apos;t change current file sizes. It only changes future growth, so it happens in predictable chunks.&lt;/p&gt;
&lt;p&gt;As written, the script covers log files as well as data files. On SQL Server 2022 and later, there&apos;s a reason to handle logs differently, which is covered below.&lt;/p&gt;
&lt;h2&gt;Why This Might Not Work for You&lt;/h2&gt;
&lt;p&gt;There&apos;s no one-size-fits-all in database management. This approach assumes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You care more about reducing VLF fragmentation than squeezing every gigabyte.&lt;/li&gt;
&lt;li&gt;You have the disk space to absorb larger growth steps.&lt;/li&gt;
&lt;li&gt;You&apos;re okay with uniformity until you get better data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you&apos;re managing a system with tight disk constraints, high-churn databases, or specific growth patterns (think OLAP vs OLTP workloads), this blanket setting might not be right. For legacy systems where nobody knows why the settings are what they are, it&apos;s a solid default until you have evidence otherwise.&lt;/p&gt;
&lt;h2&gt;One Note on Log Files and IFI&lt;/h2&gt;
&lt;p&gt;SQL Server 2022 added instant file initialization (IFI) for log file growth. Normally, SQL Server fills new file space with zeros before using it. Data files have been able to skip that for a long time, but log files always had to be zeroed first. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/databases/database-instant-file-initialization&quot;&gt;instant file initialization page&lt;/a&gt; says that starting with SQL Server 2022, &quot;transaction log autogrowth events up to 64 MB can benefit from instant file initialization,&quot; and that growth events &quot;larger than 64 MB can&apos;t.&quot; Log IFI also doesn&apos;t need the Perform Volume Maintenance Tasks privilege that data file IFI does. The same page notes that the default autogrowth for new databases is already 64 MB.&lt;/p&gt;
&lt;p&gt;A log set to grow by 1024MB still gets zeroed on every growth. Aaron Bertrand tested this in &lt;a href=&quot;https://www.red-gate.com/simple-talk/databases/sql-server/t-sql-programming-sql-server/log-file-instant-file-initialization/&quot;&gt;Log File Instant File Initialization&lt;/a&gt;, comparing 64MB and 1GB log autogrowth on SQL Server 2019 and 2022. On 2022, the 64MB setting took roughly 20% off his workload&apos;s duration by getting rid of the &lt;code&gt;PREEMPTIVE_OS_WRITEFILEGATHER&lt;/code&gt; waits spent zeroing the file.&lt;/p&gt;
&lt;p&gt;On SQL Server 2022, a 64MB log growth also creates a single VLF, according to Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-transaction-log-architecture-and-management-guide&quot;&gt;transaction log architecture guide&lt;/a&gt;, so keeping log growth at 64MB doesn&apos;t work against the VLF count the way small growths did on older versions.&lt;/p&gt;
&lt;p&gt;This only affects log files, and only on SQL Server 2022 and later. Data files still want the bigger growth, in my opinion. To apply the script to data files only and handle logs separately, add &lt;code&gt;AND mf.type = 0&lt;/code&gt; to the &lt;code&gt;WHERE&lt;/code&gt; clause.&lt;/p&gt;
</content:encoded></item><item><title>How to Safely Shrink a SQL Server Transaction Log File</title><link>https://chandlergray.com/blog/how-to-safely-shrink-a-sql-server-transaction-log-file/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-safely-shrink-a-sql-server-transaction-log-file/</guid><description>Shrink a SQL Server transaction log with DBCC SHRINKFILE, understand the risks, and apply log management practices that prevent regrowth.</description><pubDate>Sun, 11 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Your disk is running low on space. You find a blog post or Stack Overflow answer suggesting &lt;code&gt;DBCC SHRINKFILE&lt;/code&gt;, so you run it. It either seems to work or does nothing. Either way, the log eventually grows back, your I/O takes a hit, and the problem comes back a week later.&lt;/p&gt;
&lt;p&gt;Shrinking your transaction log is a temporary measure, not a fix. If you&apos;re doing it regularly, you&apos;re probably solving the wrong problem.&lt;/p&gt;
&lt;h2&gt;What DBCC SHRINKFILE Really Does&lt;/h2&gt;
&lt;p&gt;A transaction log is divided internally into virtual log files (VLFs). Shrinking a log file can only release VLFs at the end of the file that are inactive, meaning nothing in them is still needed. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-shrinkfile-transact-sql&quot;&gt;&lt;code&gt;DBCC SHRINKFILE&lt;/code&gt; documentation&lt;/a&gt; says that when part of the active log sits past the target size, SQL Server &quot;frees as much space as possible, and then issues an informational message.&quot; If the VLFs at the end are still in use, nothing gets released.&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USE [YourDatabase];
GO
DBCC SHRINKFILE (N&apos;YourDatabase_Log&apos;, 1024);  -- target size in MB
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You won&apos;t get an error. The file just doesn&apos;t shrink. And if you&apos;re in full recovery mode without frequent log backups, SQL Server can&apos;t mark old log space as reusable.&lt;/p&gt;
&lt;p&gt;The same page has a troubleshooting section for exactly this, headed &quot;The file doesn&apos;t shrink.&quot; It says &quot;a common reason for a transaction log file not to shrink is the absence of regular transaction log backups.&quot; It also says a log file &quot;can only be shrunk to a virtual log file boundary,&quot; so a target smaller than one VLF won&apos;t be reached even when the space is free.&lt;/p&gt;
&lt;h2&gt;Reproducing the Problem&lt;/h2&gt;
&lt;p&gt;Here&apos;s a simple demo you can try to see why shrinking often doesn&apos;t work:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Step 1: Create a test database
CREATE DATABASE TestShrink;
GO

-- Step 2: Set recovery model to FULL and try to shrink
ALTER DATABASE TestShrink SET RECOVERY FULL;
GO
DBCC SHRINKFILE (TestShrink_Log, 1);
GO

-- Step 3: Open a transaction that holds the log open
USE TestShrink;
BEGIN TRAN;
SELECT 1; -- Keeps the transaction open

-- Step 4: Attempt to shrink again
DBCC SHRINKFILE (TestShrink_Log, 1);
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With the transaction open and no log backups taken, the shrink command has nothing to release. SQL Server is doing its job by keeping that data safe, and that means holding onto log space.&lt;/p&gt;
&lt;p&gt;SQL Server will also tell you what the log is waiting on, in &lt;code&gt;sys.databases&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT name, log_reuse_wait_desc, recovery_model_desc
FROM sys.databases
WHERE database_id &amp;gt; 4;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/logs/the-transaction-log-sql-server#factors-that-can-delay-log-truncation&quot;&gt;transaction log documentation&lt;/a&gt; lists every value of &lt;code&gt;log_reuse_wait_desc&lt;/code&gt; and what it means. A few of the common ones:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;LOG_BACKUP&lt;/code&gt;: a log backup is needed before the log can be truncated (full or bulk-logged recovery only).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ACTIVE_TRANSACTION&lt;/code&gt;: a transaction is still open, which is what the demo above shows.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AVAILABILITY_REPLICA&lt;/code&gt;: an availability group secondary is still applying log records.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;REPLICATION&lt;/code&gt;: transactions for replication haven&apos;t been delivered to the distribution database yet.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NOTHING&lt;/code&gt;: there&apos;s reusable space in the log.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why Regular Shrinking Can Make Things Worse&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/vlf-shrink-regrow.DtEgBEL7_Z1nPvXN.webp&quot; alt=&quot;Animated four-stage loop of a transaction log being shrunk and regrown. It starts as a log file made of four large virtual log files, two of which are active and cannot be released. DBCC SHRINKFILE then truncates the tail and returns disk space, which looks like a win. Autogrowth refills the file, but now carves it into twenty-six small virtual log files, leaving it fragmented and slowing crash recovery. The final stage shows the file back at its original size but fragmented, and the loop repeats to show that shrinking is a treadmill rather than a fix.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Shrinking isn&apos;t harmless. After a shrink, the log has to grow back, and each growth adds more VLFs. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-transaction-log-architecture-and-management-guide&quot;&gt;transaction log architecture guide&lt;/a&gt; explains how many. Since SQL Server 2014, a growth smaller than one eighth of the current log size adds a single VLF. Larger growths add 4, 8 or 16 VLFs depending on their size, and on SQL Server 2022 a growth of 64 MB or less adds just one. So a log that grows back in many small steps ends up with far more VLFs than one grown back in a single step.&lt;/p&gt;
&lt;p&gt;The guide says a high VLF count slows down database recovery during startup, and restores, because SQL Server has to build a list of every VLF first. Its symptoms section describes counts in the range of several hundred thousand, and it suggests keeping the total to &quot;a maximum of several thousand.&quot;&lt;/p&gt;
&lt;p&gt;Shrinking a data file has a separate cost that the &lt;code&gt;DBCC SHRINKFILE&lt;/code&gt; documentation spells out: &quot;A shrink operation doesn&apos;t preserve the fragmentation state of indexes in the database, and can increase index fragmentation.&quot; That applies to data files rather than the log, but it&apos;s one more reason not to treat shrinking as routine maintenance.&lt;/p&gt;
&lt;p&gt;Shrinking also hides the real issues: no log backups, inefficient transaction patterns, poor autogrowth settings, or a log undersized for the workload.&lt;/p&gt;
&lt;h2&gt;How to Actually Manage Your Log File&lt;/h2&gt;
&lt;p&gt;Here&apos;s what works:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Size it right from the start. Look at historical peak usage and give the log enough room for it.&lt;/li&gt;
&lt;li&gt;Use fixed autogrowth sizes rather than percentages, so each growth adds a predictable number of VLFs.&lt;/li&gt;
&lt;li&gt;Schedule frequent log backups (for full or bulk-logged recovery). This is what lets SQL Server reuse inactive VLFs.&lt;/li&gt;
&lt;li&gt;Monitor log space so you know what&apos;s going on before reaching for the shrink button.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SQL Server 2022+:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT
    DB_NAME(database_id) AS [Database]
    ,total_log_size_mb = total_log_size_in_bytes / 1048576.0
    ,used_log_space_mb = used_log_space_in_bytes / 1048576.0
    ,percent_used = used_log_space_in_bytes * 100.0 / total_log_size_in_bytes
FROM sys.dm_db_log_space_usage;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Older versions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DBCC SQLPERF(LOGSPACE);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;When It Is Okay to Shrink&lt;/h2&gt;
&lt;p&gt;Say you just offloaded a massive archive table or ran a one-time migration, and your log grew far beyond what your workload needs. In that case, a one-time shrink is fine. Afterwards, resize the file to something appropriate and let it grow only when it needs to. Microsoft&apos;s guide describes the same approach for a log with too many VLFs. Shrink it, grow it back to the size it needs in one step with &lt;code&gt;ALTER DATABASE ... MODIFY FILE&lt;/code&gt;, then review the autogrowth settings. It also says to make sure you have a valid, restorable backup before doing any of that.&lt;/p&gt;
&lt;p&gt;If you&apos;re running scheduled log shrinks in a job, it&apos;s time to take a closer look at your backup strategy.&lt;/p&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;If your transaction log is getting too big, it&apos;s usually telling you something about your recovery model, your backup frequency, or your workload.&lt;/p&gt;
&lt;p&gt;Don&apos;t treat &lt;code&gt;DBCC SHRINKFILE&lt;/code&gt; like a maintenance task. Treat it like a fire extinguisher. Use it only when you have to, and figure out how to avoid the fire next time.&lt;/p&gt;
</content:encoded></item><item><title>Migrating Your SSRS ReportServer Database</title><link>https://chandlergray.com/blog/migrating-your-ssrs-reportserver-database/</link><guid isPermaLink="true">https://chandlergray.com/blog/migrating-your-ssrs-reportserver-database/</guid><description>Migrate an SSRS ReportServer database by backup and restore, covering encryption keys, logins, service accounts, and portal validation.</description><pubDate>Wed, 07 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import Callout from &quot;@/components/Callout.astro&quot;;&lt;/p&gt;
&lt;p&gt;Migrating an SSRS ReportServer database is deceptively simple. You copy the &lt;code&gt;.bak&lt;/code&gt; file, run RESTORE, and expect everything to work... or so I thought. Without careful handling of encryption keys, logins, and service-account mappings, you can end up with a database that won&apos;t decrypt connection strings or throws permission errors in the portal.&lt;/p&gt;
&lt;p&gt;Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/reporting-services/install-windows/migrate-a-reporting-services-installation-native-mode&quot;&gt;migration guide for Reporting Services&lt;/a&gt; calls backing up the encryption key &quot;critical to migration success.&quot; The report server database holds the reports, shared data sources, subscriptions and schedules, but the stored connection strings and credentials in it are encrypted. Restoring the key on the new server is what makes them usable again.&lt;/p&gt;
&lt;h2&gt;Export the Encryption Key&lt;/h2&gt;
&lt;p&gt;Before creating or copying any database backups, export the SSRS encryption key from the source server. In Report Server Configuration Manager, go to &lt;code&gt;Encryption Keys&lt;/code&gt;, select &lt;code&gt;Backup&lt;/code&gt;, and choose a secure path for the &lt;code&gt;.snk&lt;/code&gt; file. Give it a strong password and store both the file and the password somewhere safe.&lt;/p&gt;
&lt;h2&gt;Back Up the ReportServer Database&lt;/h2&gt;
&lt;p&gt;With the encryption key saved, take a full backup of the ReportServer database:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BACKUP DATABASE [ReportServer]
  TO DISK = N&apos;D:\Backups\ReportServer_FULL.bak&apos;
  WITH FORMAT, COMPRESSION, STATS = 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the backup completes successfully and copy the resulting &lt;code&gt;.bak&lt;/code&gt; file to the destination server&apos;s backup folder. Also double-check that the destination instance has its own fresh ReportServer backup in case you need to roll back.&lt;/p&gt;
&lt;p&gt;There&apos;s a second database, &lt;code&gt;ReportServerTempDB&lt;/code&gt;. Microsoft&apos;s guide says the report server database and the temporary database &quot;are interdependent and must be moved together,&quot; and its &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/reporting-services/install-windows/backup-and-restore-operations-for-reporting-services&quot;&gt;backup and restore page&lt;/a&gt; explains why a backup of it is worth having. You don&apos;t need the data in it, but you do need its table structure, and if it&apos;s lost, &quot;the only way to get it back is to recreate the report server database.&quot; So back up both.&lt;/p&gt;
&lt;h2&gt;Restore the Database on the Destination&lt;/h2&gt;
&lt;p&gt;I always switch the ReportServer database into single-user mode to prevent connection conflicts:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USE [master];
ALTER DATABASE [ReportServer] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restore the database, overwriting any existing copy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RESTORE DATABASE [ReportServer]
  FROM DISK = N&apos;N:\Backups\ReportServer_FULL.bak&apos;
  WITH REPLACE, STATS = 5;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Return the database to multi-user mode:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER DATABASE [ReportServer] SET MULTI_USER;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Right after, check &lt;code&gt;sys.databases&lt;/code&gt; or the SQL Server error log to make sure there were no lock or permission errors during the restore.&lt;/p&gt;
&lt;h2&gt;Reassign Database Owner and Fix Logins&lt;/h2&gt;
&lt;p&gt;SSRS requires &lt;code&gt;sa&lt;/code&gt; as the database owner. Run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USE [ReportServer];
ALTER AUTHORIZATION ON DATABASE::ReportServer TO sa;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, remap any orphaned users whose SIDs no longer match. For example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;EXEC sp_change_users_login &apos;Auto_Fix&apos;, &apos;YourUser&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-change-users-login-transact-sql&quot;&gt;documentation for &lt;code&gt;sp_change_users_login&lt;/code&gt;&lt;/a&gt; marks it as a feature that &quot;will be removed in a future version of SQL Server&quot; and points to &lt;code&gt;ALTER USER&lt;/code&gt; instead, so &lt;code&gt;ALTER USER [YourUser] WITH LOGIN = [YourUser]&lt;/code&gt; is the longer-lasting way to do the same remap. Either way, check that each database user maps to its server login afterwards.&lt;/p&gt;
&lt;h2&gt;Recreate the SSRS Service Account User&lt;/h2&gt;
&lt;p&gt;The SSRS service account (commonly &lt;code&gt;RptSrvc&lt;/code&gt;) must exist inside ReportServer and belong to the RSExecRole:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- 1. Create the service‐account login (if it doesn’t already exist)
CREATE LOGIN [DOMAIN\RptSrvc] FROM WINDOWS;
GO

-- 2. Grant rights in the ReportServer database
USE [ReportServer];
GO
CREATE USER [DOMAIN\RptSrvc] FOR LOGIN [DOMAIN\RptSrvc];
GO
EXEC sp_addrolemember &apos;RSExecRole&apos;, &apos;DOMAIN\RptSrvc&apos;;
GO

-- 3. Grant rights in the ReportServerTempDB database
USE [ReportServerTempDB];
GO
CREATE USER [DOMAIN\RptSrvc] FOR LOGIN [DOMAIN\RptSrvc];
GO
EXEC sp_addrolemember &apos;db_owner&apos;, &apos;DOMAIN\RptSrvc&apos;;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After running this, check that a simple query against an SSRS catalog table (for example &lt;code&gt;SELECT TOP 1 * FROM dbo.Catalog&lt;/code&gt;) succeeds under the &lt;code&gt;RptSrvc&lt;/code&gt; account.&lt;/p&gt;
&lt;h2&gt;Restore the Encryption Key on the Destination&lt;/h2&gt;
&lt;p&gt;Back in Report Server Configuration Manager on the destination server, open &lt;code&gt;Encryption Keys&lt;/code&gt;, choose &lt;code&gt;Restore&lt;/code&gt;, select the &lt;code&gt;.snk&lt;/code&gt; file you exported earlier, and enter the password. Microsoft&apos;s guide says this step is &quot;necessary for enabling reversible encryption on preexisting connection strings and credentials that are already in the report server database.&quot;&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;info&quot;&amp;gt;
Shared data sources are stored in the report server database, so they come across with the restore, but their saved connection details depend on the key. Microsoft&apos;s guide says to &quot;review data source information to see whether the data source connection information is still specified&quot; once the migration is done. Plan to open each shared data source in the new portal and confirm its connection string and credentials before relying on the reports that use it.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;h2&gt;Validate the Web Portal&lt;/h2&gt;
&lt;p&gt;Finally, open your SSRS web portal in a browser, go through several folders, and render a few reports. If you get a 404, an authentication failure, or a rendering error, check the SSRS service log (&lt;code&gt;ReportServerService__*.log&lt;/code&gt;) for clues, whether it&apos;s database connectivity, decryption, or URL binding, and fix the underlying issue before testing again.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Export and restore the encryption key, back up and move both databases, fix ownership and logins, recreate the service-account user, and then check the data sources. In that order, most of the usual surprises go away. I&apos;ll eventually wrap these steps into a PowerShell module, but for now the process is straightforward enough and doesn&apos;t take that long.&lt;/p&gt;
</content:encoded></item><item><title>How to Install SQL Server 2022 on MacOS</title><link>https://chandlergray.com/blog/how-to-install-sql-server-on-mac-os/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-install-sql-server-on-mac-os/</guid><description>SQL Server does not run natively on macOS, but Docker makes it straightforward. Here is how to get an instance running on a Mac.</description><pubDate>Tue, 29 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Microsoft is pretty upfront about the fact that SQL Server doesn&apos;t run natively on macOS. Back in college, I learned this the hard way. I bought a Windows license, spun up a VirtualBox VM on my 8GB MacBook Pro, and limped my way through installing SQL Server 2016 for a database class. It was miserable.&lt;/p&gt;
&lt;p&gt;If only 2015 Chandler knew about Docker.&lt;/p&gt;
&lt;h2&gt;What is Docker?&lt;/h2&gt;
&lt;p&gt;If you&apos;ve used virtual machines, think of containers as a leaner, more efficient alternative. Instead of virtualizing hardware and running a full operating system for every instance, containers share the host system&apos;s kernel and isolate the app at the process level. In our case, it&apos;s SQL Server in a pre-packaged box. Docker runs on Windows, Linux, and macOS.&lt;/p&gt;
&lt;h2&gt;Installing Docker&lt;/h2&gt;
&lt;p&gt;Macs come in two flavors: Intel and Apple Silicon. Docker Desktop supports both, but the setup steps differ slightly. Follow the &lt;a href=&quot;https://docs.docker.com/desktop/setup/install/mac-install/&quot;&gt;official Docker Desktop install guide&lt;/a&gt; and make sure to pick the version that matches your Mac.&lt;/p&gt;
&lt;p&gt;After installing, open Docker Desktop.&lt;/p&gt;
&lt;p&gt;The difference between the two kinds of Mac matters for SQL Server too. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/linux/quickstart-install-connect-docker&quot;&gt;container quickstart&lt;/a&gt; says the SQL Server images &quot;are supported only on Linux hosts running on Intel and AMD x86-64 CPUs,&quot; and that &quot;emulation or translation environments (for example, Rosetta 2, Prism, or QEMU) aren&apos;t tested or supported.&quot; On an Apple Silicon Mac, the container runs under emulation, which is outside what Microsoft supports. That&apos;s fine for learning and local testing, but if something strange happens, there&apos;s no support path for it.&lt;/p&gt;
&lt;p&gt;The same page lists at least 2 GB of RAM and 2 GB of disk space as prerequisites, so check how much memory Docker Desktop is allowed to use if the container has trouble staying up.&lt;/p&gt;
&lt;h2&gt;Install SQL Server&lt;/h2&gt;
&lt;p&gt;Actually deploying SQL Server is easy, and it&apos;s the reason this is worth doing over a VM. Open Terminal and check that Docker is working:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If that fails with &lt;code&gt;-bash: docker: command not found&lt;/code&gt;, Docker may not be in your system&apos;s PATH. I fixed this with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo ln -s /Applications/Docker.app/Contents/Resources/bin/docker /usr/local/bin/docker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;docker version&lt;/code&gt; still doesn&apos;t work, search for the error you&apos;re getting. Once it does, pull the SQL Server 2022 image:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker pull mcr.microsoft.com/mssql/server:2022-latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once that completes, start the container. Change the password before running it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run -e &quot;ACCEPT_EULA=Y&quot; -e &quot;MSSQL_SA_PASSWORD=&amp;lt;password&amp;gt;&quot; \
   -p 1433:1433 --name sql1 --hostname sql1 \
   -d \
   mcr.microsoft.com/mssql/server:2022-latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This shouldn&apos;t take long. When it&apos;s done, you have SQL Server 2022 running on your local machine on port 1433.&lt;/p&gt;
&lt;p&gt;The password has to follow SQL Server&apos;s default password policy. Microsoft&apos;s quickstart says that means at least eight characters, drawn from three of these four sets: uppercase letters, lowercase letters, digits, and symbols. If the password doesn&apos;t meet that, the page says &quot;the container can&apos;t set up SQL Server, and stops working.&quot; &lt;code&gt;docker logs sql1&lt;/code&gt; shows the error log so you can see why.&lt;/p&gt;
&lt;p&gt;The image tags on &lt;a href=&quot;https://hub.docker.com/r/microsoft/mssql-server&quot;&gt;Docker Hub&lt;/a&gt; now include &lt;code&gt;2025-latest&lt;/code&gt; alongside &lt;code&gt;2022-latest&lt;/code&gt;, &lt;code&gt;2019-latest&lt;/code&gt; and &lt;code&gt;2017-latest&lt;/code&gt;. To run a different version, swap the tag in both commands.&lt;/p&gt;
&lt;h2&gt;What now?&lt;/h2&gt;
&lt;p&gt;This setup isn&apos;t meant for production, but it&apos;s great for local testing, development, and learning. If you want to go further, these are good places to start:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://hub.docker.com/r/microsoft/mssql-server&quot;&gt;Docker Hub page for the SQL Server images&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/sql/linux/quickstart-install-connect-docker&quot;&gt;Microsoft&apos;s SQL Server on Linux container quickstart&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&apos;s SQL Server on a Mac without a VM, which would have saved 2015 me 4GB of RAM.&lt;/p&gt;
</content:encoded></item><item><title>How to Analyze Wait Stats in SQL Server 2022</title><link>https://chandlergray.com/blog/how-to-analyze-wait-stats-sql-server-2022/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-analyze-wait-stats-sql-server-2022/</guid><description>Categorize SQL Server 2022 wait stats into actionable groups with sys.dm_os_wait_stats and GROUPING SETS to find bottlenecks fast.</description><pubDate>Mon, 28 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import Callout from &quot;@/components/Callout.astro&quot;;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;warning&quot;&amp;gt;
This query is written for SQL Server 2022. It should work on other versions, but it&apos;ll likely miss some wait types that don&apos;t exist in 2022.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;p&gt;A look at wait stats can show where a server is struggling. SQL Server isn&apos;t what I would call &quot;good&quot; at describing its problems, and it doesn&apos;t fix them on its own, so the waits are one of the better places to start.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/it-hurts-when-i-do-this-meme.cKnTuWHB_d2tAM.webp&quot; alt=&quot;A frustrated doctor meme&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sys.dm_os_wait_stats&lt;/code&gt; returns a row for every wait type, and there are a lot of them. The wait types table in Microsoft&apos;s &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;documentation for the view&amp;lt;/a&amp;gt; had 953 entries as of September 2026. I don&apos;t want to memorize them, so I wanted something that filtered out the noise for me. The query below groups the ones I care about into a handful of categories and drops the rest.&lt;/p&gt;
&lt;p&gt;Before reading any of it, check how far back the numbers go. The same page says the statistics &quot;aren&apos;t persisted after the database engine restarts,&quot; and that everything is cumulative since the last restart or the last time someone cleared them. &lt;code&gt;sqlserver_start_time&lt;/code&gt; in &lt;code&gt;sys.dm_os_sys_info&lt;/code&gt; gives you the restart time, so you know whether you&apos;re looking at a day of history or a year of it.&lt;/p&gt;
&lt;p&gt;If you&apos;ve just inherited a server and need to understand its history, keep the existing wait statistics intact. Only clear the old data when you&apos;re troubleshooting a live incident and need a fresh baseline. For those live investigations, run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DBCC SQLPERF (&apos;sys.dm_os_wait_stats&apos;, CLEAR);
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Script&lt;/h2&gt;
&lt;p&gt;The script below puts each wait into one of five categories: CPU, Memory, Database Blocking, I/O or Tempdb Contention. It filters out the noise, then uses a single &lt;code&gt;GROUPING SETS&lt;/code&gt; query to return both the detailed rows and a total per category in one pass.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Wait types by category.
WITH WaitCategories AS (
    SELECT
        wait_type,
        wait_time_ms,
        signal_wait_time_ms,
        waiting_tasks_count,
        CASE 
            -- Benign Wait Types
            WHEN wait_type IN (
                &apos;BROKER_EVENTHANDLER&apos;, &apos;BROKER_RECEIVE_WAITFOR&apos;, &apos;BROKER_TASK_STOP&apos;, &apos;BROKER_TO_FLUSH&apos;,
                &apos;BROKER_TRANSMITTER&apos;, &apos;CHECKPOINT_QUEUE&apos;, &apos;CHKPT&apos;, &apos;CLR_AUTO_EVENT&apos;, &apos;CLR_MANUAL_EVENT&apos;,
                &apos;CLR_SEMAPHORE&apos;, &apos;DBMIRROR_DBM_EVENT&apos;, &apos;DBMIRROR_EVENTS_QUEUE&apos;, &apos;DBMIRROR_WORKER_QUEUE&apos;,
                &apos;DBMIRRORING_CMD&apos;, &apos;DIRTY_PAGE_POLL&apos;, &apos;DISPATCHER_QUEUE_SEMAPHORE&apos;, &apos;EXECSYNC&apos;, &apos;FSAGENT&apos;,
                &apos;FT_IFTS_SCHEDULER_IDLE_WAIT&apos;, &apos;FT_IFTSHC_MUTEX&apos;, &apos;HADR_CLUSAPI_CALL&apos;,
                &apos;HADR_FILESTREAM_IOMGR_IOCOMPLETION&apos;, &apos;HADR_LOGCAPTURE_WAIT&apos;, &apos;HADR_NOTIFICATION_DEQUEUE&apos;,
                &apos;HADR_TIMER_TASK&apos;, &apos;HADR_WORK_QUEUE&apos;, &apos;KSOURCE_WAKEUP&apos;, &apos;LAZYWRITER_SLEEP&apos;, &apos;LOGMGR_QUEUE&apos;,
                &apos;MEMORY_ALLOCATION_EXT&apos;, &apos;ONDEMAND_TASK_QUEUE&apos;, &apos;PARALLEL_REDO_DRAIN_WORKER&apos;,
                &apos;PARALLEL_REDO_LOG_CACHE&apos;, &apos;PARALLEL_REDO_TRAN_LIST&apos;, &apos;PARALLEL_REDO_WORKER_SYNC&apos;,
                &apos;PARALLEL_REDO_WORKER_WAIT_WORK&apos;, &apos;PREEMPTIVE_HADR_LEASE_MECHANISM&apos;,
                &apos;PREEMPTIVE_SP_SERVER_DIAGNOSTICS&apos;, &apos;PREEMPTIVE_OS_LIBRARYOPS&apos;, &apos;PREEMPTIVE_OS_COMOPS&apos;,
                &apos;PREEMPTIVE_OS_CRYPTOPS&apos;, &apos;PREEMPTIVE_OS_PIPEOPS&apos;, &apos;PREEMPTIVE_OS_AUTHENTICATIONOPS&apos;,
                &apos;PREEMPTIVE_OS_GENERICOPS&apos;, &apos;PREEMPTIVE_OS_VERIFYTRUST&apos;, &apos;PREEMPTIVE_OS_DELETESECURITYCONTEXT&apos;,
                &apos;PREEMPTIVE_OS_REPORTEVENT&apos;, &apos;PREEMPTIVE_OS_FILEOPS&apos;, &apos;PREEMPTIVE_OS_DEVICEOPS&apos;,
                &apos;PREEMPTIVE_OS_QUERYREGISTRY&apos;, &apos;PREEMPTIVE_OS_WRITEFILE&apos;, &apos;PREEMPTIVE_OS_WRITEFILEGATHER&apos;,
                &apos;PREEMPTIVE_XE_CALLBACKEXECUTE&apos;, &apos;PREEMPTIVE_XE_DISPATCHER&apos;, &apos;PREEMPTIVE_XE_GETTARGETSTATE&apos;,
                &apos;PREEMPTIVE_XE_SESSIONCOMMIT&apos;, &apos;PREEMPTIVE_XE_TARGETINIT&apos;, &apos;PREEMPTIVE_XE_TARGETFINALIZE&apos;,
                &apos;POPULATE_LOCK_ORDINALS&apos;, &apos;PWAIT_ALL_COMPONENTS_INITIALIZED&apos;,
                &apos;PWAIT_DIRECTLOGCONSUMER_GETNEXT&apos;, &apos;PWAIT_EXTENSIBILITY_CLEANUP_TASK&apos;,
                &apos;QDS_PERSIST_TASK_MAIN_LOOP_SLEEP&apos;, &apos;QDS_ASYNC_QUEUE&apos;,
                &apos;QDS_CLEANUP_STALE_QUERIES_TASK_MAIN_LOOP_SLEEP&apos;, &apos;REQUEST_FOR_DEADLOCK_SEARCH&apos;,
                &apos;RESOURCE_QUEUE&apos;, &apos;SERVER_IDLE_CHECK&apos;, &apos;SLEEP_BPOOL_FLUSH&apos;, &apos;SLEEP_DBSTARTUP&apos;,
                &apos;SLEEP_DCOMSTARTUP&apos;, &apos;SLEEP_MASTERDBREADY&apos;, &apos;SLEEP_MASTERMDREADY&apos;, &apos;SLEEP_MASTERUPGRADED&apos;,
                &apos;SLEEP_MSDBSTARTUP&apos;, &apos;SLEEP_SYSTEMTASK&apos;, &apos;SLEEP_TASK&apos;, &apos;SLEEP_TEMPDBSTARTUP&apos;,
                &apos;SNI_HTTP_ACCEPT&apos;, &apos;SOS_WORK_DISPATCHER&apos;, &apos;SP_SERVER_DIAGNOSTICS_SLEEP&apos;,
                &apos;SOS_WORKER_MIGRATION&apos;, &apos;VDI_CLIENT_OTHER&apos;, &apos;SQLTRACE_BUFFER_FLUSH&apos;,
                &apos;SQLTRACE_INCREMENTAL_FLUSH_SLEEP&apos;, &apos;SQLTRACE_WAIT_ENTRIES&apos;, &apos;STARTUP_DEPENDENCY_MANAGER&apos;,
                &apos;TRACEWRITE&apos;, &apos;WAIT_FOR_RESULTS&apos;, &apos;WAITFOR&apos;, &apos;WAITFOR_TASKSHUTDOWN&apos;, &apos;WAIT_XTP_HOST_WAIT&apos;,
                &apos;WAIT_XTP_OFFLINE_CKPT_NEW_LOG&apos;, &apos;WAIT_XTP_CKPT_CLOSE&apos;, &apos;WAIT_XTP_RECOVERY&apos;,
                &apos;XE_BUFFERMGR_ALLPROCESSED_EVENT&apos;, &apos;XE_DISPATCHER_JOIN&apos;, &apos;XE_DISPATCHER_WAIT&apos;,
                &apos;XE_LIVE_TARGET_TVF&apos;, &apos;XE_TIMER_EVENT&apos;)
                THEN &apos;Benign&apos;
            -- CPU Wait Types
            WHEN wait_type IN (
                &apos;CXPACKET&apos;, &apos;CXCONSUMER&apos;, &apos;SOS_SCHEDULER_YIELD&apos;, &apos;THREADPOOL&apos;)
                THEN &apos;CPU&apos;
            -- Memory Wait Types
            WHEN wait_type IN (
                &apos;MEMORY_GRANT_UPDATE&apos;, &apos;RESOURCE_SEMAPHORE&apos;, &apos;RESOURCE_SEMAPHORE_QUERY_COMPILE&apos;)
                THEN &apos;Memory&apos;
            -- Database Blocking Wait Types
            WHEN wait_type LIKE &apos;LCK_M_%&apos; THEN &apos;Database Blocking&apos;
            -- I/O Wait Types
            WHEN wait_type IN (
                &apos;WRITELOG&apos;, &apos;BACKUPIO&apos;, &apos;ASYNC_IO_COMPLETION&apos;, &apos;PAGEIOLATCH_SH&apos;,
                &apos;PAGEIOLATCH_UP&apos;, &apos;PAGEIOLATCH_EX&apos;, &apos;IO_COMPLETION&apos;, &apos;WRITE_COMPLETION&apos;, &apos;LOGBUFFER&apos;,
                &apos;LOGMGR&apos;, &apos;LOGMGR_RESERVE_APPEND&apos;, &apos;DISKIO_SUSPEND&apos;)
                THEN &apos;I/O&apos;
            -- Tempdb Contention Wait Types
            WHEN wait_type IN (
                &apos;PAGELATCH_EX&apos;, &apos;LATCH_EX&apos;, &apos;PAGELATCH_SH&apos;, &apos;LATCH_SH&apos;, &apos;PAGELATCH_UP&apos;)
                THEN &apos;Tempdb Contention&apos;
            -- Other Wait Types
            ELSE &apos;Other&apos;
        END AS WaitCategory
    FROM sys.dm_os_wait_stats
)
-- Combine detailed and aggregated results using GROUPING SETS
SELECT
    CASE
        WHEN GROUPING(wait_type) = 0 THEN &apos;Detailed&apos;
        ELSE &apos;Aggregated&apos;
    END AS ResultType,
    WaitCategory,
    wait_type,
    SUM(wait_time_ms) AS wait_time_ms,
    SUM(signal_wait_time_ms) AS signal_wait_time_ms,
    SUM(waiting_tasks_count) AS waiting_tasks_count
FROM WaitCategories
WHERE 1=1
    AND WaitCategory NOT IN (&apos;Benign&apos;, &apos;Other&apos;) -- Exclude &apos;Benign&apos; and &apos;Other&apos; categories
    AND waiting_tasks_count &amp;gt; 0
GROUP BY
    GROUPING SETS (
        (WaitCategory, wait_type), -- Detailed results
        (WaitCategory)             -- Aggregated results
    )
ORDER BY
    CASE
        WHEN GROUPING(wait_type) = 1 THEN 1 -- Place aggregated results at the bottom
        ELSE 0
    END,
    wait_time_ms DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;How to Read the Results&lt;/h2&gt;
&lt;p&gt;Rows labeled &lt;code&gt;Detailed&lt;/code&gt; show each wait type the query tracks, and rows labeled &lt;code&gt;Aggregated&lt;/code&gt; total each category. The detailed rows are for drilling in, and the aggregates at the bottom give a high-level view of where SQL Server is spending most of its time.&lt;/p&gt;
&lt;p&gt;Don&apos;t fixate on the single top wait type. A category made up of several smaller waits can add up to more than the one wait at the top of the list, so compare the category totals and not just the peaks.&lt;/p&gt;
&lt;p&gt;The filter at the end of the query drops two categories. &lt;code&gt;Benign&lt;/code&gt; is the list of waits that are safe to ignore, and I got that list from query 42 in &lt;a href=&quot;https://www.dropbox.com/scl/fi/lpo8vfr7qext2lflkqa3j/SQL-Server-2022-Diagnostic-Information-Queries.sql?rlkey=57bn3xd1sxw9sq2m17woiqmj4&amp;amp;e=1&amp;amp;dl=0&quot;&gt;Glenn Berry&apos;s DMVs&lt;/a&gt;. &lt;code&gt;Other&lt;/code&gt; is everything that isn&apos;t in one of my categories, which means a wait type the query doesn&apos;t know about won&apos;t show up at all. If a server looks fine by this query and clearly isn&apos;t, changing &lt;code&gt;AND WaitCategory NOT IN (&apos;Benign&apos;, &apos;Other&apos;)&lt;/code&gt; to &lt;code&gt;AND WaitCategory &amp;lt;&amp;gt; &apos;Benign&apos;&lt;/code&gt; shows what&apos;s been hiding in &lt;code&gt;Other&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The categories are my own, and the Tempdb Contention label is the loosest of them. &lt;code&gt;PAGELATCH_EX&lt;/code&gt; waits happen in tempdb, but they also happen in user databases when many sessions insert rows at the end of an index on a sequential column, like an identity. Microsoft&apos;s article on &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/troubleshoot/sql/database-engine/performance/resolve-pagelatch-ex-contention&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;resolving PAGELATCH_EX contention&amp;lt;/a&amp;gt; covers that case, which it calls last-page insert contention. It also gives a way to tell the two apart. The &lt;code&gt;wait_resource&lt;/code&gt; for a waiting session starts with the database ID, and an ID of 5 or higher is a user database, while 2 is tempdb. So if this query points at Tempdb Contention, check that before adding tempdb files.&lt;/p&gt;
</content:encoded></item><item><title>How to Configure and Tune MAXDOP</title><link>https://chandlergray.com/blog/how-to-configure-and-tune-maxdop/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-configure-and-tune-maxdop/</guid><description>A practical guide to selecting and configuring the Max Degree of Parallelism (MAXDOP) setting in SQL Server for optimal parallel query performance.</description><pubDate>Sun, 27 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When I first started learning SQL Server administration, I read a lot of articles on configuring Max Degree of Parallelism (MAXDOP). Most of them described it as a limit on how many processors a query can use. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-max-degree-of-parallelism-server-configuration-option&quot;&gt;documentation&lt;/a&gt; is more specific. The limit applies per task, and it &quot;isn&apos;t a per request or per query limit.&quot; A single parallel request can start several tasks, each using one worker and one scheduler, so a plan with more than one parallel branch can have more workers running than the number you set.&lt;/p&gt;
&lt;p&gt;A setting of 0 lets SQL Server use all available processors, up to 64, and the same page says that &quot;isn&apos;t the recommended value for most cases.&quot; A setting of 1 stops SQL Server from generating parallel plans. Any value from 1 to 32,767 sets the limit, and if it&apos;s higher than the number of processors available, SQL Server uses the number available.&lt;/p&gt;
&lt;h2&gt;How to set MAXDOP&lt;/h2&gt;
&lt;p&gt;There are a lot of conflicting blog posts about how to set MAXDOP, so I fall back to Microsoft&apos;s recommendation. It isn&apos;t right for every server, but sometimes it fits as-is. These are Microsoft&apos;s current recommendations for SQL Server 2016 and newer:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server configuration&lt;/th&gt;
&lt;th&gt;Number of processors&lt;/th&gt;
&lt;th&gt;Guidance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server with single NUMA node&lt;/td&gt;
&lt;td&gt;Less than or equal to eight logical processors&lt;/td&gt;
&lt;td&gt;Keep MAXDOP at or under the number of logical processors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server with single NUMA node&lt;/td&gt;
&lt;td&gt;Greater than eight logical processors&lt;/td&gt;
&lt;td&gt;Keep MAXDOP at 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server with multiple NUMA nodes&lt;/td&gt;
&lt;td&gt;Less than or equal to 16 logical processors per NUMA node&lt;/td&gt;
&lt;td&gt;Keep MAXDOP at or under the number of logical processors per NUMA node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server with multiple NUMA nodes&lt;/td&gt;
&lt;td&gt;Greater than 16 logical processors per NUMA node&lt;/td&gt;
&lt;td&gt;Keep MAXDOP at half the number of logical processors per NUMA node, with a maximum value of 16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The NUMA nodes in that table are the soft-NUMA nodes SQL Server creates on its own when it detects more than eight physical cores per NUMA node or socket, or the hardware NUMA nodes if soft-NUMA is turned off. The page says the recommendations are aimed at keeping all the worker threads of a parallel query inside one of those nodes.&lt;/p&gt;
&lt;p&gt;To make this simpler, I rewrote the table as a T-SQL script that gives me the recommendation without working it out by hand:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DECLARE @maxdop INT
	,@cpu_count INT
	,@numa_count INT
	,@cpu_per_node INT
	,@recommended INT;

SELECT @maxdop = CONVERT(INT, value_in_use)
FROM sys.configurations
WHERE name = &apos;max degree of parallelism&apos;;

SELECT @cpu_count = cpu_count
	,@numa_count = numa_node_count
FROM sys.dm_os_sys_info;

IF @numa_count = 1
BEGIN
	IF @cpu_count &amp;lt;= 8
		SET @recommended = @cpu_count;
	ELSE
		SET @recommended = 8;
END
ELSE
BEGIN
	SET @cpu_per_node = @cpu_count / @numa_count;

	IF @cpu_per_node &amp;lt;= 16
		SET @recommended = @cpu_per_node;
	ELSE
		SET @recommended = CASE 
				WHEN (@cpu_per_node / 2) &amp;gt; 16
					THEN 16
				ELSE (@cpu_per_node / 2)
				END;
END

SELECT @maxdop AS Current_MAXDOP
	,@recommended AS Recommended_MAXDOP
	,CASE 
		WHEN @maxdop = @recommended
			THEN &apos;Pass&apos;
		ELSE &apos;Fail&apos;
		END AS MAXDOP_Status;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script divides &lt;code&gt;cpu_count&lt;/code&gt; by &lt;code&gt;numa_node_count&lt;/code&gt; from &lt;code&gt;sys.dm_os_sys_info&lt;/code&gt;. The &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-os-sys-info-transact-sql&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;documentation for that view&amp;lt;/a&amp;gt; says &lt;code&gt;numa_node_count&lt;/code&gt; &quot;includes physical NUMA nodes and soft NUMA nodes,&quot; which matches what the table is counting. The column exists from SQL Server 2016 SP2 onward, so the script won&apos;t run on anything older.&lt;/p&gt;
&lt;p&gt;The results show the current value, the recommended one, and whether they match. To make the change, put the recommended value into this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USE master;
GO
EXEC sp_configure &apos;show advanced options&apos;, 1;
GO
RECONFIGURE WITH OVERRIDE;
GO

EXEC sp_configure &apos;max degree of parallelism&apos;, 8; -- &amp;lt;-- Set your recommended value here.
GO
RECONFIGURE WITH OVERRIDE;
GO

EXEC sp_configure &apos;show advanced options&apos;, 0;
GO
RECONFIGURE;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;How to Monitor MAXDOP&lt;/h2&gt;
&lt;p&gt;This is where things can get messy if you overthink them. Changing MAXDOP will change query performance, so I measure both the slowest query I have and a reasonably fast one first. Then I set MAXDOP to Microsoft&apos;s recommendation and keep an eye on overall performance over the next few hours, days and weeks.&lt;/p&gt;
&lt;p&gt;If queries get slower, check the query plan. MAXDOP is likely too high if queries that used to run fast on a single thread are now using more threads without running any faster, or are waiting longer to start. If it&apos;s too low, you&apos;ll see the opposite, with queries stuck on one thread doing work that could have been split up. Don&apos;t be afraid to adjust it as you learn more about the server.&lt;/p&gt;
&lt;p&gt;The cost threshold for parallelism is worth looking at alongside it. MAXDOP limits how wide a parallel plan can go, and the cost threshold decides whether SQL Server considers a parallel plan at all. The &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-cost-threshold-for-parallelism-server-configuration-option&quot;&gt;cost threshold documentation&lt;/a&gt; says the default of 5 &quot;is a starting point, not a recommendation,&quot; and that raising it can keep smaller OLTP queries on serial plans. It suggests changing it in small increments and watching a full business cycle between changes. The same page has a short table of symptoms. When the threshold is too low, too many light queries go parallel and &lt;code&gt;CXPACKET&lt;/code&gt; and &lt;code&gt;CXCONSUMER&lt;/code&gt; dominate the wait stats. When it&apos;s too high, CPU-heavy queries that should go parallel don&apos;t, and &lt;code&gt;SOS_SCHEDULER_YIELD&lt;/code&gt; dominates instead.&lt;/p&gt;
&lt;p&gt;One detail on that page is easy to miss. A query can still get a parallel plan when its cost is below the threshold, because the decision is made from a cost estimate taken earlier in optimization than the final plan cost you see. So a cheap query running in parallel isn&apos;t proof the threshold is set wrong.&lt;/p&gt;
&lt;h2&gt;Using Query OPTION (MAXDOP n)&lt;/h2&gt;
&lt;p&gt;Some DBAs, or a sly developer, might use the &lt;code&gt;OPTION (MAXDOP n)&lt;/code&gt; query hint to force a specific degree of parallelism on a specific query. In most cases this isn&apos;t a recommended practice, and it&apos;s best suited to measuring and troubleshooting performance at different settings. You can use it in production. Here&apos;s an example of the hint:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT column1, column2
FROM VeryLargeTable
OPTION (MAXDOP 4);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting MAXDOP at the database level probably won&apos;t be the right choice for every query, so the query level may be the only way to satisfy stakeholders. Keep track of the queries using this hint and use it sparingly. If the server ever changes, it&apos;s easier to change the setting once than to find every query with a hint and remeasure it.&lt;/p&gt;
&lt;p&gt;The query hint is one of three ways to override the server setting. The MAXDOP documentation lists the query level (the hint, or a Query Store hint), the database level through a database scoped configuration, and the workload level through a Resource Governor workload group. When a query&apos;s parallelism doesn&apos;t match the server setting, one of those is usually the reason.&lt;/p&gt;
&lt;p&gt;Two newer features can also explain a value you didn&apos;t set yourself. Starting with SQL Server 2019, setup recommends a MAXDOP value during installation based on the processors it finds, so a newer install may already match the table. SQL Server 2022 added degree of parallelism (DOP) feedback, which adjusts parallelism for repeating queries based on their elapsed time and waits.&lt;/p&gt;
&lt;p&gt;No two shops are the same. Follow the Microsoft recommendation until it no longer works for you, and keep measuring after the change so that problems show up before the 2:00 AM wake-up calls do.&lt;/p&gt;
</content:encoded></item><item><title>Optimizing SQL Server Backup Transfers with Robocopy</title><link>https://chandlergray.com/blog/optimizing-sql-server-backup-transfers-with-robocopy/</link><guid isPermaLink="true">https://chandlergray.com/blog/optimizing-sql-server-backup-transfers-with-robocopy/</guid><description>Split a large .bak file, benchmark Robocopy multithreading in PowerShell, run parallel copies, and reassemble the backup on the far end.</description><pubDate>Tue, 22 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This morning I stared at a 30 GB SQL Server backup and a VPN that managed about 1 MB/s and dropped every 45 to 60 minutes. I thought, &quot;If I split this into thirty 1 GB pieces and copy them in parallel, maybe I can outrun the disconnects.&quot; Later that morning I ran a quick PowerShell script to slice the .bak into 30 parts, which took just a couple of minutes.&lt;/p&gt;
&lt;p&gt;I tested transfer times next. At around 1 MB/s, each slice copied in roughly 15 to 17 minutes. In a typical VPN window I could finish two slices and start another before the link dropped. When the VPN hiccuped, I lost at most one gigabyte instead of dozens, but the overall transfer time stayed the same. Moving 30 GB at 1 MB/s still takes about eight hours, plus a few extra minutes for splitting and retries. I had smaller failure domains, but no net time savings.&lt;/p&gt;
&lt;p&gt;By midday I was frustrated and decided to tune Robocopy&apos;s parallelism. I wrote a simple harness to copy the first handful of slices with different &lt;code&gt;/MT&lt;/code&gt; thread counts and measured each run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$sourceDir   = &apos;\\server\share&apos;
$destDir     = &apos;D:\migrations&apos;
$filePattern = &apos;large.bak.part00[1-5]&apos;
$mtOptions   = 2,4,8,16,32
$results     = @{}

foreach ($mt in $mtOptions) {
    $start = Get-Date
    robocopy $sourceDir $destDir $filePattern /Z /MT:$mt /R:1 /W:1 | Out-Null
    $results[$mt] = (Get-Date) - $start
}

$bestMt = ($results.GetEnumerator() |
           Sort-Object Value.TotalSeconds |
           Select-Object -First 1).Key

Write-Host &quot;Best MT value is&quot; $bestMt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That test took on the order of half an hour, and &lt;code&gt;/MT:8&lt;/code&gt; came out on top. It&apos;s worth knowing that 8 is also Robocopy&apos;s default. The &lt;a href=&quot;https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/robocopy&quot;&gt;robocopy documentation&lt;/a&gt; says &lt;code&gt;/mt&lt;/code&gt; accepts a value &quot;between 1 and 128&quot; and that &quot;the default value for &lt;em&gt;n&lt;/em&gt; is 8.&quot; The test also ran over the same link that dropped every 45 to 60 minutes, so a run that happened to hit a disconnect would look slower than it really was.&lt;/p&gt;
&lt;p&gt;With that, I launched the full copy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;robocopy \\server\share D:\migrations large.bak.part* /Z /MT:8 /R:3 /W:5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;/R:3 /W:5&lt;/code&gt; on that command matter more than they look. The same page lists Robocopy&apos;s defaults as 1,000,000 retries with a 30-second wait between each, so on an unreliable link it&apos;s worth setting both yourself. The page also lists a &lt;code&gt;/compress&lt;/code&gt; switch that &quot;requests network compression during file transfer, if applicable,&quot; which could be worth testing when bandwidth is the limit.&lt;/p&gt;
&lt;p&gt;Next, to avoid a single long list of files, I ran three Robocopy sessions in parallel, each handling a batch of slices:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$parts  = Get-ChildItem &apos;\\server\share\large.bak.part*&apos; | Sort-Object Name
$groups = @(
    $parts[0..9],
    $parts[10..19],
    $parts[20..29]
)

foreach ($batch in $groups) {
    $fileArgs = $batch.Name -join &apos; &apos;
    Start-Process robocopy -ArgumentList &apos;\\server\share&apos;, &apos;D:\migrations&apos;, $fileArgs, &apos;/Z&apos;, &apos;/MT:8&apos;, &apos;/R:3&apos;, &apos;/W:5&apos; -NoNewWindow
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I let those jobs run unattended into the afternoon and evening, and by the end of the day all 30 parts had arrived successfully.&lt;/p&gt;
&lt;p&gt;Finally, to rebuild the original .bak file once all the parts have transferred, you can stitch them back together with a PowerShell loop in your migration folder:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$parts  = Get-ChildItem &apos;D:\migrations\large.bak.part*&apos; | Sort-Object Name
$out    = &apos;D:\migrations\restored-large.bak&apos;
if (Test-Path $out) { Remove-Item $out }
foreach ($part in $parts) {
    Get-Content $part -Encoding Byte -ReadCount 0 | Add-Content $out -Encoding Byte
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This reads each slice in order and writes it into &lt;code&gt;restored-large.bak&lt;/code&gt;. After that, you can point &lt;code&gt;RESTORE DATABASE&lt;/code&gt; at &lt;code&gt;restored-large.bak&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That loop only works in Windows PowerShell 5.1. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/get-content&quot;&gt;&lt;code&gt;Get-Content&lt;/code&gt; documentation&lt;/a&gt; says the &lt;code&gt;-AsByteStream&lt;/code&gt; parameter &quot;was introduced in Windows PowerShell 6.0,&quot; and &lt;code&gt;Byte&lt;/code&gt; is no longer one of the &lt;code&gt;-Encoding&lt;/code&gt; values. On PowerShell 7, use &lt;code&gt;Get-Content $part -AsByteStream -ReadCount 0 | Add-Content $out -AsByteStream&lt;/code&gt;. The &lt;code&gt;-ReadCount 0&lt;/code&gt; matters in either version. The default of 1 &quot;reads one byte in each read operation and converts each byte into a separate object,&quot; which on a 30 GB file is billions of objects.&lt;/p&gt;
&lt;p&gt;Or, from a classic CMD prompt, run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;copy /b large.bak.part* restored-large.bak
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Either method stitches the chunks back into the original backup file, ready to restore. Before you run the restore, check that the reassembled file is the same size as the original, and run &lt;code&gt;RESTORE VERIFYONLY&lt;/code&gt; against it.&lt;/p&gt;
&lt;p&gt;Today I learned that chopping a large file into pieces rarely speeds up the total transfer, because the bandwidth doesn&apos;t change. What splitting does give you is a smaller failure domain, so a dropped VPN costs one gigabyte instead of everything. Pair that with Robocopy&apos;s restartable mode (&lt;code&gt;/Z&lt;/code&gt;) and sensible retry settings, and you have a straightforward, resilient workflow. When your VPN inevitably drops, you&apos;ll be glad you let a tested tool handle the resume logic for you.&lt;/p&gt;
</content:encoded></item><item><title>Querying Dates in SQL Server for Performance</title><link>https://chandlergray.com/blog/querying-dates-in-sql-server-for-performance/</link><guid isPermaLink="true">https://chandlergray.com/blog/querying-dates-in-sql-server-for-performance/</guid><description>Six date-filtering patterns benchmarked, showing how non-sargable expressions force scans and inflate logical reads, plus what to use instead.</description><pubDate>Tue, 31 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Query tuning is often more than speed. It&apos;s also about understanding how SQL Server processes data and which patterns give the right results. I tested how six different ways of filtering dates affect performance, using &lt;code&gt;SET STATISTICS IO ON&lt;/code&gt; and &lt;code&gt;SET STATISTICS TIME ON&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Setup&lt;/h2&gt;
&lt;p&gt;I set myself the task of retrieving user activity from the &lt;code&gt;Users&lt;/code&gt; table in the StackOverflow database for the year 2017, since the copy I have only has data up through 2018. The table has millions of rows, so I thought it would make a good example. There&apos;s a nonclustered index covering this query, which I&apos;ve scripted below. &lt;code&gt;LastAccessDate&lt;/code&gt; is a &lt;code&gt;datetime&lt;/code&gt; column.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;USE [StackOverflow]
GO

SET ANSI_PADDING ON
GO

CREATE NONCLUSTERED INDEX [IX_LastAccessDate_DisplayName_Reputation] ON [dbo].[Users]
(
	[LastAccessDate] ASC,
	[DisplayName] ASC,
	[Reputation] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, FILLFACTOR = 100, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here are the six approaches I tested, along with my results.&lt;/p&gt;
&lt;p&gt;1. &lt;code&gt;BETWEEN&lt;/code&gt; with precise timestamps. This is probably the most common method I see in the wild.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2024-12-31-134858.BGT57749_Z9Hlxi.webp&quot; alt=&quot;Query stats with BETWEEN&quot; /&gt;&lt;/p&gt;
&lt;p&gt;2. &lt;code&gt;&amp;gt;=&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt;, again with precise timestamps. This one is also fairly common.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2024-12-31-134403.BOJEx_IS_fdPtQ.webp&quot; alt=&quot;Query stats with greater than or less than&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Nothing unusual so far. Both queries returned 1,923,109 rows with 11,167 logical reads, ran fast enough for my needs, and gave me the data I expected. Let&apos;s keep going.&lt;/p&gt;
&lt;p&gt;3. &lt;code&gt;BETWEEN&lt;/code&gt; again, but with plain dates instead of timestamps. I wanted to see whether the less precise date format would make the query non-sargable, meaning unable to use the index to seek.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2024-12-31-140013.DzISX6Ht_ZFEV6r.webp&quot; alt=&quot;Query stats with BETWEEN without precise timestamps&quot; /&gt;&lt;/p&gt;
&lt;p&gt;There&apos;s no real change in speed or reads, but if you look closely, it returned 1,920,209 rows, which is 2,900 fewer than the first two queries. More on that below.&lt;/p&gt;
&lt;p&gt;4. The same idea with &lt;code&gt;&amp;gt;=&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt;. Nothing significant to report. It returned the same 1,923,109 rows with the same 11,167 reads as the first two.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2024-12-31-140828.D-lJYsqL_Z21Ci0K.webp&quot; alt=&quot;Query stats with greater than or equal to or less than&quot; /&gt;&lt;/p&gt;
&lt;p&gt;5. &lt;code&gt;BETWEEN&lt;/code&gt; again, but converting the &lt;code&gt;datetime&lt;/code&gt; column to a &lt;code&gt;VARCHAR&lt;/code&gt; to change the date format.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2024-12-31-141440.2fuDltB-_Z2vMc7S.webp&quot; alt=&quot;Query stats with BETWEEN but using VARCHAR&quot; /&gt;&lt;/p&gt;
&lt;p&gt;6. The same &lt;code&gt;VARCHAR&lt;/code&gt; conversion as step 5, with &lt;code&gt;&amp;gt;=&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2024-12-31-141630.Dl8KqQcm_ZRgtCE.webp&quot; alt=&quot;Query stats with greater than or equal to or less than but using VARCHAR&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Both &lt;code&gt;CONVERT&lt;/code&gt; versions returned the full 1,923,109 rows, but logical reads went from about 11,000 to about 53,000, and CPU time went from about 15 seconds to about 41 seconds. They also show a scan count of 9 instead of 1.&lt;/p&gt;
&lt;h2&gt;About those missing rows&lt;/h2&gt;
&lt;p&gt;Query 3 is the one worth going back to. &lt;code&gt;BETWEEN&lt;/code&gt; includes both ends of the range, but on a &lt;code&gt;datetime&lt;/code&gt; column, &lt;code&gt;&apos;2017-12-31&apos;&lt;/code&gt; means midnight at the start of December 31st. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/language-elements/between-transact-sql&quot;&gt;&lt;code&gt;BETWEEN&lt;/code&gt; documentation&lt;/a&gt; has an example of exactly this. When the time part isn&apos;t specified, &quot;it defaults to 12:00 A.M.,&quot; so any row later than that on the last day falls outside the range. That&apos;s where the 2,900 rows went, and the query gave no sign that anything was missing.&lt;/p&gt;
&lt;p&gt;Query 1 has a subtler version of the same problem. &lt;code&gt;datetime&lt;/code&gt; values are rounded to increments of .000, .003 or .007 seconds, and Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/data-types/datetime-transact-sql&quot;&gt;&lt;code&gt;datetime&lt;/code&gt; documentation&lt;/a&gt; shows &lt;code&gt;&apos;23:59:59.999&apos;&lt;/code&gt; being stored as &lt;code&gt;00:00:00.000&lt;/code&gt; on the following day. So &lt;code&gt;BETWEEN &apos;2017-01-01 00:00:00.000&apos; AND &apos;2017-12-31 23:59:59.999&apos;&lt;/code&gt; actually ends at midnight on January 1st, 2018, and would include any row stamped exactly at that moment. It returned the same count as query 2 here, so there weren&apos;t any in this data, but the range isn&apos;t what it looks like.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&amp;gt;= &apos;2017-01-01&apos; AND &amp;lt; &apos;2018-01-01&apos;&lt;/code&gt; avoids both problems. It also keeps working on a &lt;code&gt;datetime2&lt;/code&gt; column, which is accurate to 100 nanoseconds, where an end value like &lt;code&gt;23:59:59.997&lt;/code&gt; would leave out anything in the last few fractions of a second.&lt;/p&gt;
&lt;h2&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;Logical reads went up almost five-fold with &lt;code&gt;CONVERT&lt;/code&gt;. Wrapping the column in a function means SQL Server has to convert every row&apos;s value before it can compare it, so it can&apos;t seek on the index. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide&quot;&gt;index design guide&lt;/a&gt; defines a sargable predicate as &quot;a &lt;strong&gt;S&lt;/strong&gt;earch &lt;strong&gt;ARG&lt;/strong&gt;ument&lt;strong&gt;able&lt;/strong&gt; predicate that can use an index to speed up the execution of the query,&quot; and the jump in reads here is what losing that looks like.&lt;/p&gt;
&lt;p&gt;On the sargable versions, &lt;code&gt;BETWEEN&lt;/code&gt; and &lt;code&gt;&amp;gt;=&lt;/code&gt;/&lt;code&gt;&amp;lt;&lt;/code&gt; performed about the same, so the reason to prefer &lt;code&gt;&amp;gt;=&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt; is the boundary rather than speed. I&apos;ll be avoiding &lt;code&gt;CONVERT&lt;/code&gt; on my &lt;code&gt;datetime&lt;/code&gt; columns, and &lt;code&gt;BETWEEN&lt;/code&gt; on anything with a time component.&lt;/p&gt;
</content:encoded></item><item><title>Using PowerShell to Automate Glenn Berry&apos;s SQL DMV Queries</title><link>https://chandlergray.com/blog/using-powershell-to-automate-glenn-berrys-sql-dmv-queries/</link><guid isPermaLink="true">https://chandlergray.com/blog/using-powershell-to-automate-glenn-berrys-sql-dmv-queries/</guid><description>A dbatools and ImportExcel script that runs Glenn Berry&apos;s diagnostic DMV queries and compiles the results into one Excel workbook.</description><pubDate>Sun, 22 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I find myself using &lt;a href=&quot;https://glennsqlperformance.com/resources/&quot;&gt;Glenn Berry&apos;s SQL Server DMV queries&lt;/a&gt; more than any other performance tuning or diagnostic tool. Because I run them so often, I needed a way to quickly connect to a server, collect the data, and move on until I have time to review it properly. In my role, speed and accuracy are important, so any chance I get to automate something, I take it.&lt;/p&gt;
&lt;p&gt;Glenn Berry&apos;s scripts are already well documented, and I recommend running them manually to understand what each one is telling you. I wanted to streamline the process further, so I wrote a PowerShell script that runs the diagnostic queries for me and saves the results to review later. It&apos;s also useful when troubleshooting an issue in real time.&lt;/p&gt;
&lt;h2&gt;What Is dbatools?&lt;/h2&gt;
&lt;p&gt;The script is built on &lt;a href=&quot;https://dbatools.io/&quot;&gt;dbatools&lt;/a&gt;, the PowerShell module that comes up in most of my automation posts. The &lt;a href=&quot;https://docs.dbatools.io/&quot;&gt;dbatools documentation&lt;/a&gt; and &lt;a href=&quot;https://github.com/dataplat/dbatools&quot;&gt;GitHub repository&lt;/a&gt; are the places to start, and I have a short post on &lt;a href=&quot;https://chandlergray.com/blog/installing-dbatools/&quot;&gt;installing it&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The Script&lt;/h2&gt;
&lt;p&gt;Most of the real work is done by &lt;a href=&quot;https://dbatools.io/Invoke-DbaDiagnosticQuery&quot;&gt;Invoke-DbaDiagnosticQuery&lt;/a&gt; in dbatools, which the command&apos;s documentation credits to Andre Kamman. The same page says the most recent version of Glenn Berry&apos;s queries is included in the dbatools module, so there&apos;s nothing separate to download. My script is a wrapper around it that connects to a SQL Server instance, runs the instance-level queries and optionally the database-specific ones, exports the results to CSV files, and compiles them into a single Excel workbook.&lt;/p&gt;
&lt;h3&gt;Accessing the Script&lt;/h3&gt;
&lt;p&gt;The script is available on &lt;a href=&quot;https://github.com/ChandlerCGray/SQL-PowerShell-Scripts/blob/main/Glenn%20Berry&apos;s%20DMV&apos;s%20using%20PowerShell.ps1&quot;&gt;my GitHub repository&lt;/a&gt;. If I change anything, that&apos;s where the update will be.&lt;/p&gt;
&lt;h3&gt;Prerequisites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;PowerShell Execution Policy: Your execution policy has to allow scripts to run. &lt;code&gt;Get-ExecutionPolicy&lt;/code&gt; shows the current one, and the &lt;a href=&quot;https://chandlergray.com/blog/installing-dbatools/&quot;&gt;dbatools install post&lt;/a&gt; has the setting I use.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Required Modules: The script will try to install dbatools and ImportExcel on its own, but it&apos;s good practice to install them manually first to avoid any potential issues.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Install-Module -Name dbatools -Scope CurrentUser
Install-Module -Name ImportExcel -Scope CurrentUser
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;How to Use the Script&lt;/h2&gt;
&lt;p&gt;Running it is mostly a matter of answering prompts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Download the Script&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Visit the &lt;a href=&quot;https://github.com/ChandlerCGray/SQL-PowerShell-Scripts/blob/main/Glenn%20Berry&apos;s%20DMV&apos;s%20using%20PowerShell.ps1&quot;&gt;GitHub repository&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Download the script to a local directory. It&apos;s stored in the repository as &lt;code&gt;Glenn Berry&apos;s DMV&apos;s using PowerShell.ps1&lt;/code&gt;, and the examples below use the name from the script&apos;s own help text, &lt;code&gt;Export-SQLDiagnosticData.ps1&lt;/code&gt;, so rename it or adjust the commands.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Run the Script&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Open PowerShell.&lt;/li&gt;
&lt;li&gt;Navigate to the directory containing the script.&lt;/li&gt;
&lt;li&gt;Execute the script:&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;.\Export-SQLDiagnosticData.ps1
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Respond to Prompts&lt;/strong&gt;:
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SQL Server Name&lt;/strong&gt;: Enter the name of the SQL Server instance you wish to connect to. If you press Enter without typing anything, it defaults to &lt;code&gt;localhost&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Export Path&lt;/strong&gt;: Specify the root directory where you want the export files to be saved. The default is &lt;code&gt;C:\Users\YourUsername\Documents\SQLDiagnosticData&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authentication Method&lt;/strong&gt;:
&lt;ul&gt;
&lt;li&gt;Type &lt;code&gt;1&lt;/code&gt; for &lt;strong&gt;Windows Authentication&lt;/strong&gt; or &lt;code&gt;2&lt;/code&gt; for &lt;strong&gt;SQL Server Authentication&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If you choose SQL Server Authentication, you&apos;ll be prompted to enter your SQL Server credentials.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run Database-Specific Queries&lt;/strong&gt;: Type &lt;code&gt;Y&lt;/code&gt; to run database-specific diagnostic queries or &lt;code&gt;N&lt;/code&gt; to skip. The default is &lt;code&gt;N&lt;/code&gt;.
&lt;ul&gt;
&lt;li&gt;If you select &lt;code&gt;Y&lt;/code&gt;, a list of databases on the server will be displayed.&lt;/li&gt;
&lt;li&gt;Enter the numbers corresponding to the databases you want to analyze, separated by commas (for example &lt;code&gt;1,3,5&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wait for Completion&lt;/strong&gt;:
&lt;ul&gt;
&lt;li&gt;The script will connect to the SQL Server instance, execute the diagnostic queries, export the results to CSV files, and merge them into an Excel workbook.&lt;/li&gt;
&lt;li&gt;Progress and any relevant messages will be displayed in the console.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Review the Results&lt;/strong&gt;:
&lt;ul&gt;
&lt;li&gt;Navigate to the export path you specified.&lt;/li&gt;
&lt;li&gt;Open the &lt;code&gt;SQLDiagnostics.xlsx&lt;/code&gt; file to review the collected data.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Pro Tip&lt;/h3&gt;
&lt;p&gt;You can skip most of the prompts by passing the answers as parameters:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.\Export-SQLDiagnosticData.ps1 -SqlInstance &quot;YourServerName&quot; -ExportRootPath &quot;C:\Your\Export\Path&quot; -RunDatabaseQueries -DatabaseNames &quot;DB1&quot;,&quot;DB2&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing to keep in mind when reading the results is that many of these DMVs, including wait stats and index usage, are cumulative since the last restart. So a single run shows everything since the server started rather than what it&apos;s doing right now.&lt;/p&gt;
&lt;p&gt;As always, I&apos;d love to hear feedback on this script. If you have suggestions for making it better, leave a comment and I&apos;ll try to add them.&lt;/p&gt;
</content:encoded></item><item><title>Identifying and Removing Duplicate Indexes in SQL Server</title><link>https://chandlergray.com/blog/identifying-and-removing-duplicate-indexes-in-sql-server/</link><guid isPermaLink="true">https://chandlergray.com/blog/identifying-and-removing-duplicate-indexes-in-sql-server/</guid><description>Query catalog views with a CTE over index key and included columns to find exact duplicates, then generate DROP statements to reclaim space.</description><pubDate>Fri, 13 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In an earlier post, I mentioned I was looking for ways to simplify my performance tuning. One of the approaches I&apos;ve taken is finding exact duplicate indexes. Duplicate indexes are a problem because any time you insert, update, or delete a row in that table, SQL Server has to update the same index more than once. I don&apos;t like doing the same task twice, so I don&apos;t want SQL Server to have to do it either.&lt;/p&gt;
&lt;h2&gt;Why Duplicate Indexes Are Problematic&lt;/h2&gt;
&lt;p&gt;For this post, a duplicate is an index with the same key columns, in the same order and sort direction, and the same included columns as another index on the same table. That&apos;s the definition the query below uses.&lt;/p&gt;
&lt;p&gt;Issues caused by duplicate indexes include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Storage&lt;/strong&gt;: Each index takes disk space, so a duplicate is space you don&apos;t need.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Slower writes&lt;/strong&gt;: Every &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; has to maintain both copies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Longer maintenance&lt;/strong&gt;: Index rebuilds and statistics updates have one more index to work through.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Script&lt;/h2&gt;
&lt;p&gt;This is the query I use to find exact duplicate indexes in a database. Running it is straightforward, and I&apos;ve broken it into parts below for anyone who wants to see how it works.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;;WITH CTE_IndexData
AS (
	SELECT s.name AS SchemaName
		,t.name AS TableName
		,i.name AS IndexName
		-- Key columns of the index
		,STUFF((
				SELECT &apos;, &apos; + c.name + &apos; &apos; + CASE 
						WHEN ic.is_descending_key = 1
							THEN &apos;DESC&apos;
						ELSE &apos;ASC&apos;
						END
				FROM sys.index_columns ic
				INNER JOIN sys.columns c ON ic.object_id = c.object_id
					AND ic.column_id = c.column_id
				WHERE ic.object_id = i.object_id
					AND ic.index_id = i.index_id
					AND ic.is_included_column = 0
				ORDER BY ic.key_ordinal
				FOR XML PATH(&apos;&apos;)
					,TYPE
				).value(&apos;.&apos;, &apos;NVARCHAR(MAX)&apos;), 1, 2, &apos;&apos;) AS KeyColumnList
		-- Included columns of the index
		,STUFF((
				SELECT &apos;, &apos; + c.name
				FROM sys.index_columns ic
				INNER JOIN sys.columns c ON ic.object_id = c.object_id
					AND ic.column_id = c.column_id
				WHERE ic.object_id = i.object_id
					AND ic.index_id = i.index_id
					AND ic.is_included_column = 1
				ORDER BY ic.key_ordinal
				FOR XML PATH(&apos;&apos;)
					,TYPE
				).value(&apos;.&apos;, &apos;NVARCHAR(MAX)&apos;), 1, 2, &apos;&apos;) AS IncludeColumnList
		,i.is_disabled
		-- Generate the DROP INDEX statement
		,&apos;DROP INDEX [&apos; + i.name + &apos;] ON [&apos; + s.name + &apos;].[&apos; + t.name + &apos;]&apos; AS DropIndexStatement
	FROM sys.indexes i
	INNER JOIN sys.tables t ON i.object_id = t.object_id
	INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
	WHERE t.is_ms_shipped = 0
		AND i.type_desc IN (
			&apos;NONCLUSTERED&apos;
			,&apos;CLUSTERED&apos;
			)
	)
SELECT d1.SchemaName
	,d1.TableName
	,d1.IndexName
	,d1.KeyColumnList
	,d1.IncludeColumnList
	,d1.is_disabled
	,d1.DropIndexStatement
FROM CTE_IndexData d1
WHERE EXISTS (
		SELECT 1
		FROM CTE_IndexData d2
		WHERE d1.SchemaName = d2.SchemaName
			AND d1.TableName = d2.TableName
			AND d1.KeyColumnList = d2.KeyColumnList
			AND ISNULL(d1.IncludeColumnList, &apos;&apos;) = ISNULL(d2.IncludeColumnList, &apos;&apos;)
			AND d1.IndexName &amp;lt;&amp;gt; d2.IndexName
		)
ORDER BY d1.SchemaName
	,d1.TableName
	,d1.KeyColumnList;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;How It Works&lt;/h2&gt;
&lt;h3&gt;Gathering Basic Index Information&lt;/h3&gt;
&lt;p&gt;First, I use a Common Table Expression (CTE) called &lt;code&gt;CTE_IndexData&lt;/code&gt; to gather some basic information about the indexes that already exist:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT s.name AS SchemaName
    ,t.name AS TableName
    ,i.name AS IndexNam
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Retrieve Key Columns&lt;/h3&gt;
&lt;p&gt;Next, I create a comma-separated list of the key columns for each index, including the sort order (ASC/DESC). A subquery concatenates the column names and sort orders into a single string using the &lt;code&gt;FOR XML PATH(&apos;&apos;)&lt;/code&gt; trick, and &lt;code&gt;STUFF&lt;/code&gt; removes the leading comma and space.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;,STUFF((
    SELECT &apos;, &apos; + c.name + &apos; &apos; + CASE 
            WHEN ic.is_descending_key = 1
                THEN &apos;DESC&apos;
            ELSE &apos;ASC&apos;
            END
    FROM sys.index_columns ic
    INNER JOIN sys.columns c ON ic.object_id = c.object_id
        AND ic.column_id = c.column_id
    WHERE ic.object_id = i.object_id
        AND ic.index_id = i.index_id
        AND ic.is_included_column = 0
    ORDER BY ic.key_ordinal
    FOR XML PATH(&apos;&apos;)
        ,TYPE
    ).value(&apos;.&apos;, &apos;NVARCHAR(MAX)&apos;), 1, 2, &apos;&apos;) AS KeyColumnList
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Retrieve Included Columns&lt;/h3&gt;
&lt;p&gt;This creates a comma-separated list of included columns for each index. This works similarly to the key columns retrieval but filters on &lt;code&gt;is_included_column = 1&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;,STUFF((
    SELECT &apos;, &apos; + c.name
    FROM sys.index_columns ic
    INNER JOIN sys.columns c ON ic.object_id = c.object_id
        AND ic.column_id = c.column_id
    WHERE ic.object_id = i.object_id
        AND ic.index_id = i.index_id
        AND ic.is_included_column = 1
    ORDER BY ic.key_ordinal
    FOR XML PATH(&apos;&apos;)
        ,TYPE
    ).value(&apos;.&apos;, &apos;NVARCHAR(MAX)&apos;), 1, 2, &apos;&apos;) AS IncludeColumnList
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Drop Index Statement&lt;/h3&gt;
&lt;p&gt;This part is just to script out the &lt;code&gt;DROP INDEX&lt;/code&gt; statement. It has nothing to do with finding duplicates. It was just a &quot;nice to have.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;,i.is_disabled
,&apos;DROP INDEX [&apos; + i.name + &apos;] ON [&apos; + s.name + &apos;].[&apos; + t.name + &apos;]&apos; AS DropIndexStatement
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Find the Duplicates in my Results&lt;/h3&gt;
&lt;p&gt;Lastly, the main query finds the duplicates by comparing key and included columns. It joins the &lt;code&gt;CTE_IndexData&lt;/code&gt; to itself, as &lt;code&gt;d1&lt;/code&gt; and &lt;code&gt;d2&lt;/code&gt;, so we can compare indexes within the same table.&lt;/p&gt;
&lt;p&gt;Here&apos;s how it works:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Different Index Names&lt;/strong&gt;: &lt;code&gt;d1.IndexName &amp;lt;&amp;gt; d2.IndexName&lt;/code&gt; stops an index from matching itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same Schema and Table&lt;/strong&gt;: Both sides have to be on the same table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same Key Columns&lt;/strong&gt;: &lt;code&gt;d1.KeyColumnList = d2.KeyColumnList&lt;/code&gt;. The list includes sort order, so an &lt;code&gt;ASC&lt;/code&gt; and a &lt;code&gt;DESC&lt;/code&gt; index on the same column don&apos;t match.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same Included Columns&lt;/strong&gt;: &lt;code&gt;ISNULL(d1.IncludeColumnList, &apos;&apos;) = ISNULL(d2.IncludeColumnList, &apos;&apos;)&lt;/code&gt;, so two indexes with no included columns compare as equal instead of as &lt;code&gt;NULL&lt;/code&gt; against &lt;code&gt;NULL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;SELECT d1.SchemaName
    ,d1.TableName
    ,d1.IndexName
    ,d1.KeyColumnList
    ,d1.IncludeColumnList
    ,d1.is_disabled
    ,d1.DropIndexStatement
FROM CTE_IndexData d1
WHERE EXISTS (
    SELECT 1
    FROM CTE_IndexData d2
    WHERE d1.SchemaName = d2.SchemaName
        AND d1.TableName = d2.TableName
        AND d1.KeyColumnList = d2.KeyColumnList
        AND ISNULL(d1.IncludeColumnList, &apos;&apos;) = ISNULL(d2.IncludeColumnList, &apos;&apos;)
        AND d1.IndexName &amp;lt;&amp;gt; d2.IndexName
    )
ORDER BY d1.SchemaName
    ,d1.TableName
    ,d1.KeyColumnList;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Limitations&lt;/h2&gt;
&lt;p&gt;There are a number of things this query doesn&apos;t check for, so take precautions when dropping any index. When deciding which of the pair to drop, it&apos;s best to drop the one with fewer reads, which this query shows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT o.name AS TableName
    ,i.name AS IndexName
    ,i.type_desc AS IndexType
    ,u.user_seeks AS UserSeeks
    ,u.user_scans AS UserScans
    ,u.user_lookups AS UserLookups
    ,u.user_updates AS UserUpdates
    ,u.last_user_seek AS LastUserSeek
    ,u.last_user_scan AS LastUserScan
    ,u.last_user_lookup AS LastUserLookup
    ,u.last_user_update AS LastUserUpdate
FROM sys.dm_db_index_usage_stats AS u
INNER JOIN sys.indexes AS i ON u.object_id = i.object_id
    AND u.index_id = i.index_id
INNER JOIN sys.objects AS o ON i.object_id = o.object_id
WHERE u.database_id = DB_ID()
    --AND o.name = &apos;Badges&apos; -- Filter by table name
ORDER BY o.name
     ,i.name;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before you trust those numbers, check how long the server has been up. The &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-db-index-usage-stats-transact-sql&quot;&gt;&lt;code&gt;sys.dm_db_index_usage_stats&lt;/code&gt; documentation&lt;/a&gt; says &quot;the counters are initialized to empty whenever the database engine is started,&quot; so an index that looks unused might be one nobody has needed since the last reboot. &lt;code&gt;SELECT sqlserver_start_time FROM sys.dm_os_sys_info&lt;/code&gt; tells you the window you&apos;re actually looking at. A monthly report that runs on the first is invisible in three days of data, and if you drop its index because the counters say zero, you&apos;ll find out about the report when it next runs.&lt;/p&gt;
&lt;p&gt;A restart isn&apos;t the only thing that clears them. The same page says &quot;whenever a database is detached or is shut down (for example, because AUTO_CLOSE is set to ON), all rows associated with the database are removed.&quot; So on a database quiet enough for &lt;code&gt;AUTO_CLOSE&lt;/code&gt; to keep closing it, the counters can be empty no matter how long the instance has been up, and the uptime check won&apos;t show it.&lt;/p&gt;
&lt;p&gt;The query also doesn&apos;t check whether either index is unique, or compare compression or fill factor, just to name a few, so check those yourself. A unique index needs the most care, since it enforces a constraint as well as serving reads, and dropping it changes what the table allows rather than just how it performs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;As always, test changes in a non-production environment before making them in production.&lt;/strong&gt; In other words, don&apos;t ruin your Friday.&lt;/p&gt;
&lt;p&gt;Before dropping an index, script out its &lt;code&gt;CREATE INDEX&lt;/code&gt; statement and keep it somewhere, so putting it back doesn&apos;t depend on remembering the definition.&lt;/p&gt;
</content:encoded></item><item><title>How to Improve SQL Server Performance by Avoiding Key Lookups</title><link>https://chandlergray.com/blog/how-to-improve-sql-server-performance-by-avoiding-key-lookups/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-improve-sql-server-performance-by-avoiding-key-lookups/</guid><description>Use SET STATISTICS IO and execution plans to spot key lookups, then build covering indexes with INCLUDE to cut the extra logical reads.</description><pubDate>Tue, 10 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Recently I&apos;ve been looking into simplifying my performance tuning. One of the more straightforward ways to improve a query is to get rid of key lookups. A key lookup happens when SQL Server finds rows through a nonclustered index but needs columns that aren&apos;t in that index. It goes back to the clustered index for each row to get the rest. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/showplan-logical-and-physical-operators-reference&quot;&gt;operator reference&lt;/a&gt; calls it &quot;a bookmark lookup on a table with a clustered index,&quot; and the same thing on a heap shows up as a RID Lookup. Either way, it&apos;s extra I/O for every row the index finds.&lt;/p&gt;
&lt;h2&gt;Investigating the I/O Performance&lt;/h2&gt;
&lt;p&gt;Since I want to focus on I/O, I start by running &lt;code&gt;SET STATISTICS IO ON&lt;/code&gt; to measure logical reads for each query, and I also turn on the actual execution plan to check for index suggestions from the optimizer.&lt;/p&gt;
&lt;p&gt;This is the query, against a &lt;code&gt;Users&lt;/code&gt; table:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT DisplayName, UpVotes, DownVotes
FROM Users
WHERE DisplayName = &apos;Alex&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;STATISTICS IO&lt;/code&gt; output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Table &apos;Users&apos;. Scan count 7, logical reads 320359, physical reads 0, page server reads 0, read-ahead reads 316400, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The number that matters here is 320,359 logical reads. The table has no indexes except the clustered index on the primary key, and the execution plan suggests creating this one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE NONCLUSTERED INDEX [IX_Users_DisplayName]
ON Users (DisplayName)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Adding the Index&lt;/h2&gt;
&lt;p&gt;After adding the index, I ran the query again. The results already show an improvement:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Table &apos;Users&apos;. Scan count 7, logical reads 76773, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s a big drop in logical reads, but 76,773 still seems high for such a simple query. What&apos;s left is the key lookups. &lt;code&gt;UpVotes&lt;/code&gt; and &lt;code&gt;DownVotes&lt;/code&gt; aren&apos;t part of the new index, so SQL Server goes back to the clustered index to get them for every row the index finds.&lt;/p&gt;
&lt;p&gt;The suggested index only had &lt;code&gt;DisplayName&lt;/code&gt; in it. Missing index suggestions can include columns, and Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/indexes/tune-nonclustered-missing-index-suggestions&quot;&gt;guide to missing index suggestions&lt;/a&gt; lists included columns among the things they suggest. The same guide is clear that they&apos;re estimates made before the query runs, and that they &quot;aren&apos;t prescriptions to create indexes exactly as suggested.&quot; So it&apos;s worth checking whether an index built from a suggestion actually covers the query.&lt;/p&gt;
&lt;h2&gt;Including Columns&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;UpVotes&lt;/code&gt; and &lt;code&gt;DownVotes&lt;/code&gt; aren&apos;t part of the &lt;code&gt;WHERE&lt;/code&gt; clause, so they don&apos;t need to be key columns. I added them to the &lt;code&gt;INCLUDE&lt;/code&gt; clause of the index instead:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE NONCLUSTERED INDEX [IX_Users_DisplayName_INCLUDES]
ON Users (DisplayName)
INCLUDE (UpVotes, DownVotes)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/key-lookup-hop.BRzAV6AE_Zu1Vy4.webp&quot; alt=&quot;Animated side-by-side comparison of a key lookup versus a covering index. On the left, labelled nonclustered only, a seek on IX_Users_DisplayName repeatedly bounces to the clustered index to fetch the UpVotes and DownVotes columns that the index does not contain, with the logical read counter climbing to 76,773 and the caption noting one extra hop per row. On the right, labelled covering index, IX_Users_DisplayName_INCLUDES with UpVotes and DownVotes in the INCLUDE clause answers the query in place with no trip to the table, settling at 32 logical reads.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Then I ran the query one last time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Table &apos;Users&apos;. Scan count 1, logical reads 32, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With the key lookups gone, logical reads went from 320,359 to 32, and the scan count went from 7 to 1.&lt;/p&gt;
&lt;p&gt;Keep in mind that creating several small indexes like this will slow down &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; operations, just like any index does, so use them sparingly and create covering indexes where they make sense. Microsoft&apos;s guide makes a related point. Before creating an index from a suggestion, look at the indexes already on the table and combine them where you can, instead of adding one that overlaps. Here, that means &lt;code&gt;IX_Users_DisplayName&lt;/code&gt; is redundant once &lt;code&gt;IX_Users_DisplayName_INCLUDES&lt;/code&gt; exists, since they have the same key. Monitoring and maintaining your indexes is just as important as creating new ones.&lt;/p&gt;
</content:encoded></item><item><title>How to Download and Install the Latest Cumulative Update for SQL Server</title><link>https://chandlergray.com/blog/how-to-download-and-install-the-latest-cumulative-update-for-sql-server/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-download-and-install-the-latest-cumulative-update-for-sql-server/</guid><description>Locate the newest cumulative update for your SQL Server version, prepare backups and downtime, and apply the CU safely to your instances.</description><pubDate>Mon, 09 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Microsoft isn&apos;t perfect, so it releases regular updates to SQL Server. They come in a few forms, but the one I see most is the Cumulative Update (CU). Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/troubleshoot/sql/releases/servicing-models-sql-server&quot;&gt;servicing model page&lt;/a&gt; gives the schedule for SQL Server 2017 and later. CUs come out every month for the first year after a release, and every two months for the remaining four years of the five-year mainstream lifecycle. A version that has been out for a while is on the two-month schedule, which helps when planning maintenance windows.&lt;/p&gt;
&lt;p&gt;Below are the steps to download and install the latest CU on your SQL Server.&lt;/p&gt;
&lt;h2&gt;Download the Latest CU&lt;/h2&gt;
&lt;p&gt;The latest CU for each supported version is listed on &lt;a href=&quot;https://learn.microsoft.com/en-us/troubleshoot/sql/releases/download-and-install-latest-updates#latest-updates-available-for-currently-supported-versions-of-sql-server&quot;&gt;Microsoft&apos;s release page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Each cumulative update is specific to a SQL Server version (SQL Server 2019, 2022, and so on), so check which version you&apos;re on first by running this in SSMS:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT @@VERSION
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before you go looking, check that your version still gets CUs. The servicing page says &quot;CU31 for 2017 is the last CU for SQL Server 2017,&quot; and that Microsoft no longer ships CUs for SQL Server 2016 and earlier, only on-demand fixes and security updates.&lt;/p&gt;
&lt;p&gt;From the release page, select your version, and you&apos;ll be taken to the KB page for that CU. Read the documentation for your CU, then scroll down to the section marked &lt;code&gt;How to obtain or download this or the latest cumulative update package&lt;/code&gt; and click the link labeled &lt;code&gt;Download the latest cumulative update package for SQL Server 20xx now&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That takes you to one more page, where you download the CU itself. It&apos;s usually an &lt;code&gt;.exe&lt;/code&gt; named something like &lt;code&gt;SQLServer20xx-KBxxxxxxx-x64.exe&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Installing the CU&lt;/h2&gt;
&lt;p&gt;Installing a CU requires downtime for your SQL Server, so get a maintenance window before you start. &lt;strong&gt;Make sure you have a recent database backup before installing the CU.&lt;/strong&gt; It can be the difference between a 15-minute maintenance task and a 3-hour phone call with your boss, sales, and an angry client. Once that&apos;s done, right-click the &lt;code&gt;.exe&lt;/code&gt; and choose &lt;strong&gt;Run as Administrator&lt;/strong&gt; to open the SQL Server Update installer.&lt;/p&gt;
&lt;p&gt;The installer walks you through a few steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Accept the License Terms&lt;/strong&gt;: Read through and accept the license agreement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Select SQL Server Instances&lt;/strong&gt;: The installer detects the SQL Server instances on the machine. Choose the ones you want to update.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File Check&lt;/strong&gt;: The installer checks that the files it needs aren&apos;t in use. When it finishes, click &lt;code&gt;Next&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Summary&lt;/strong&gt;: Review the summary to make sure it&apos;s accurate, and click &lt;code&gt;Update&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The update takes a few minutes, depending on your system and SQL Server configuration. The selected instances may be unavailable while it runs, so this is when users start calling if they weren&apos;t told about the downtime.&lt;/p&gt;
&lt;p&gt;When it finishes, check that every operation shows &lt;code&gt;Successful&lt;/code&gt; and close the installer.&lt;/p&gt;
&lt;h2&gt;Post Installation Tasks&lt;/h2&gt;
&lt;p&gt;I&apos;ve seen some people suggest restarting SQL Server after applying a CU, but unless the installer asks for it, I don&apos;t find that necessary. To check that the update was applied, open SSMS, connect to your instance, and run this query again:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT @@VERSION
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This time you should see the CU after the version number, along with the KB number (for example &lt;code&gt;Microsoft SQL Server 20xx (RTM-CUxx) (KBxxxxxxx)...&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;If the install failed and you need to roll back to the previous version, the instructions for uninstalling that KB are at the bottom of the KB article.&lt;/p&gt;
&lt;h2&gt;CUs and GDRs&lt;/h2&gt;
&lt;p&gt;There&apos;s a second kind of update to know about. A General Distribution Release (GDR) is an update Microsoft releases for issues with broad impact, security issues, or both, and it&apos;s the kind that comes through Windows Update. The servicing page says a GDR &quot;can have either an RTM baseline or a CU baseline,&quot; and each baseline has its own line of GDRs. So a server that has only ever received GDRs is on a different line of builds than one that gets CUs. The page has examples for working out which line a given server is on. Its general advice is that &quot;Microsoft always recommends that you install the latest CU that&apos;s available for a given SQL Server version.&quot;&lt;/p&gt;
&lt;p&gt;As always, if you run into issues or need help troubleshooting, leave a comment below and I or someone in the community can help.&lt;/p&gt;
</content:encoded></item><item><title>How to Download and Install Microsoft SQL Server 2022</title><link>https://chandlergray.com/blog/how-to-download-and-install-microsoft-sql-server-2022/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-download-and-install-microsoft-sql-server-2022/</guid><description>Download the official SQL Server 2022 installer, choose between Developer and Express editions, and run the Basic or Custom setup wizard.</description><pubDate>Sun, 08 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Installing SQL Server 2022 is a straightforward process if you follow the right steps. This covers downloading the installer, running it, and two settings worth checking once it&apos;s done.&lt;/p&gt;
&lt;h2&gt;Downloading SQL Server 2022&lt;/h2&gt;
&lt;p&gt;The latest official release is on &lt;a href=&quot;https://www.microsoft.com/en-us/sql-server/sql-server-downloads&quot;&gt;Microsoft&apos;s SQL Server Downloads page&lt;/a&gt;. If you&apos;re looking for an older or specific version, it&apos;s on the &lt;a href=&quot;https://www.microsoft.com/en-us/evalcenter&quot;&gt;Microsoft Evaluation Center&lt;/a&gt; under the SQL Server section.&lt;/p&gt;
&lt;p&gt;The download asks you to choose between Developer Edition and Express Edition. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/sql-server/editions-and-components-of-sql-server-2022&quot;&gt;editions page&lt;/a&gt; says Developer Edition &quot;includes all the functionality of Enterprise edition, but is licensed for use as a development and test system, not as a production server.&quot; Express Edition is free, and the same page caps it at a 10 GB database and 1,410 MB of memory for the buffer pool.&lt;/p&gt;
&lt;p&gt;For this guide, I&apos;m using Developer Edition.&lt;/p&gt;
&lt;h2&gt;Installing SQL Server 2022&lt;/h2&gt;
&lt;p&gt;After downloading the installer, copy &lt;code&gt;SQL2022-SSEI-Dev.exe&lt;/code&gt; (or &lt;code&gt;SQL2022-SSEI-Expr.exe&lt;/code&gt; for Express) to the machine where you want to install SQL Server. Right-click the file and choose &quot;Run as Administrator&quot; to start.&lt;/p&gt;
&lt;p&gt;Once the installer opens, you&apos;ll have three options:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Basic&lt;/strong&gt;: A quick installation with default settings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom&lt;/strong&gt;: Walks you through the full setup wizard so you can choose each setting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Download Media&lt;/strong&gt;: Downloads the full installation files to use later.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For most installations, &lt;strong&gt;Basic&lt;/strong&gt; is enough. Accept the License Terms, select your installation location, and click &lt;strong&gt;Install&lt;/strong&gt;. It usually takes a few minutes, depending on your hardware and Windows version.&lt;/p&gt;
&lt;h2&gt;Two settings to check afterwards&lt;/h2&gt;
&lt;p&gt;Basic doesn&apos;t walk you through the wizard&apos;s configuration pages, and two of those pages set values that affect how the instance behaves. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/sql-server/install/instance-configuration&quot;&gt;setup documentation&lt;/a&gt; describes what each one does when it&apos;s skipped.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Max degree of parallelism (MAXDOP).&lt;/strong&gt; Starting with SQL Server 2019, setup works out a recommended MAXDOP from the number of cores. If the page is skipped, the documentation says setup uses that recommended value &quot;instead of the default Database Engine value for previous versions (0).&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Max server memory.&lt;/strong&gt; If the Memory page is skipped, setup uses the Database Engine default of 2,147,483,647 MB, which in practice means no limit. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/server-memory-server-configuration-options&quot;&gt;server memory configuration guidance&lt;/a&gt; covers how to pick a value, and it&apos;s worth reading before you set one.&lt;/p&gt;
&lt;p&gt;This shows what the instance actually ended up with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT name, value_in_use
FROM sys.configurations
WHERE name IN (&apos;max degree of parallelism&apos;, &apos;max server memory (MB)&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Post-Installation&lt;/h2&gt;
&lt;p&gt;When installation completes, setup shows the configuration it used and confirms whether it succeeded. If something went wrong, it writes a log file with more detail that you can use to troubleshoot.&lt;/p&gt;
&lt;p&gt;If you cancel the installation partway through, clean up any leftover files in the installation directory and uninstall any partially installed SQL Server components. Otherwise, they can cause problems the next time you try to install.&lt;/p&gt;
&lt;p&gt;This guide only covers installation, not security, best practices, or database configuration. With the installation done, SQL Server 2022 is ready for creating new databases or migrating existing ones.&lt;/p&gt;
&lt;p&gt;If you run into an error, search for the error code first, since most installation errors have known solutions. If that doesn&apos;t turn anything up, feel free to leave a comment.&lt;/p&gt;
</content:encoded></item><item><title>How to Download and Install SQL Server Management Studio (SSMS)</title><link>https://chandlergray.com/blog/how-to-download-and-install-sql-server-management-studio/</link><guid isPermaLink="true">https://chandlergray.com/blog/how-to-download-and-install-sql-server-management-studio/</guid><description>Find the latest SSMS installer, run it as administrator, and know what to expect from the wizard so you can start managing instances.</description><pubDate>Sun, 08 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;SQL Server Management Studio (SSMS) is Microsoft&apos;s official tool for working with SQL Server. For database administrators who need to read and write T-SQL and review execution plans, it&apos;s close to essential. Developers use SSMS too, and there are other options like the &lt;a href=&quot;https://code.visualstudio.com/docs/languages/tsql&quot;&gt;SQL Server extension for Visual Studio Code&lt;/a&gt;, but it doesn&apos;t offer the same experience. I personally prefer SSMS, though I do more DBA work than development work.&lt;/p&gt;
&lt;p&gt;When I first wrote this post, SSMS came as a single installer, &lt;code&gt;SSMS-Setup-ENU.exe&lt;/code&gt;. That changed with SSMS 21. It now installs through the Visual Studio Installer, and what you download is a small bootstrapper rather than SSMS itself. If you&apos;re looking for &lt;code&gt;SSMS-Setup-ENU.exe&lt;/code&gt; because that&apos;s what you used last time, it&apos;s no longer how the current version installs.&lt;/p&gt;
&lt;h2&gt;Downloading SSMS&lt;/h2&gt;
&lt;p&gt;The latest release is linked from Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/ssms/install/install&quot;&gt;SQL Server Management Studio install page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The download is &lt;code&gt;vs_SSMS.exe&lt;/code&gt;. Microsoft&apos;s page calls it &quot;a stub installer, or &lt;em&gt;bootstrapper&lt;/em&gt;.&quot; It installs the Visual Studio Installer, which then installs SSMS, and the page says plainly that &quot;there&apos;s no standalone MSI.&quot;&lt;/p&gt;
&lt;h2&gt;Installing SSMS&lt;/h2&gt;
&lt;p&gt;Once the download completes, go to where it was saved and double-click &lt;code&gt;vs_SSMS.exe&lt;/code&gt;. You need administrator permissions on the machine, and if you get a User Account Control prompt, choose &lt;strong&gt;Yes&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The Visual Studio Installer opens with a few optional choices:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Workloads&lt;/strong&gt;: Groups of extra features. The page gives the Hybrid and Migration workload as an example, for assessing upgrade readiness and migrating data. None of them are required for SSMS itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Individual components&lt;/strong&gt;: The same idea at a finer grain, if you&apos;d rather pick individual pieces than a whole workload.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Installation location&lt;/strong&gt;: Worth deciding now. Microsoft&apos;s page says you can choose a different drive for SSMS and the download cache &quot;only when you first install SSMS,&quot; and changing it later means uninstalling and reinstalling.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Then click &lt;strong&gt;Install&lt;/strong&gt;. It can take anywhere from a few minutes to half an hour, depending on your system.&lt;/p&gt;
&lt;h2&gt;Post-Installation&lt;/h2&gt;
&lt;p&gt;When the installation finishes, you can open SSMS from the Start Menu or by searching for it. A reboot usually isn&apos;t required, but if you see a prompt to restart or run into any issues, it&apos;s a good idea to go ahead and do it.&lt;/p&gt;
&lt;h2&gt;Versions&lt;/h2&gt;
&lt;p&gt;SSMS 22 works with SQL Server 2014 and later, according to the install page, and it can be installed side by side with other versions of SSMS. If you need to manage something older than SQL Server 2014, Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/ssms/release-history&quot;&gt;release history&lt;/a&gt; page has links to earlier versions.&lt;/p&gt;
&lt;p&gt;As of September 2026, the current release is 22.10.1. Only the latest release gets fixes. The &lt;a href=&quot;https://learn.microsoft.com/en-us/ssms/support-policy&quot;&gt;support policy&lt;/a&gt; says &quot;the SSMS team doesn&apos;t actively back-port fixes to an earlier release,&quot; so if something is broken on an older install, the first step is to update and see whether it&apos;s still broken.&lt;/p&gt;
&lt;p&gt;This guide only covers installation. Connecting to remote servers, configuring databases, and security are all worth reading up on separately. If you run into issues along the way, feel free to leave a comment.&lt;/p&gt;
</content:encoded></item><item><title>Resolving SQL Server Instance and Database Collation Discrepancies: Step-by-Step Guide</title><link>https://chandlergray.com/blog/resolving-sql-server-instance-and-database-collation-discrepancies-step-by-step-guide/</link><guid isPermaLink="true">https://chandlergray.com/blog/resolving-sql-server-instance-and-database-collation-discrepancies-step-by-step-guide/</guid><description>Script out configs, logins, and jobs, rebuild system databases with setup.exe to fix a collation mismatch, then restore everything after.</description><pubDate>Thu, 25 Jul 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Recently I ran into a situation while migrating a database to a new SQL Server instance where the instance collation was different from the database collation. &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;ORDER BY&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, and other operations started failing with collation conflict errors. I had a strict deadline to get these applications migrated, so I decided to rebuild the instance collation to match the new application databases.&lt;/p&gt;
&lt;p&gt;Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/collations/set-or-change-the-server-collation&quot;&gt;Set or Change the Server Collation&lt;/a&gt; page lays out the full sequence, and it&apos;s more than the rebuild on its own. You script out your user databases, export the data with something like bcp, drop all the user databases, rebuild master with &lt;code&gt;/SQLCOLLATION&lt;/code&gt;, then recreate everything and import it back. I skipped the export and import steps because I was restoring the databases from backups afterward anyway, but it&apos;s worth reading the documented sequence before you decide this is the route you want.&lt;/p&gt;
&lt;p&gt;Temp tables inherit tempdb&apos;s collation, which comes from the instance. The same page lists what the server collation governs, including the &quot;collation of the &lt;code&gt;CHAR&lt;/code&gt;, &lt;code&gt;VARCHAR&lt;/code&gt;, &lt;code&gt;NCHAR&lt;/code&gt;, and &lt;code&gt;NVARCHAR&lt;/code&gt; columns in system views, system functions, and the objects in &lt;code&gt;tempdb&lt;/code&gt; (for example, temporary tables).&quot; That&apos;s why the errors show up where they do. Temp tables get the instance&apos;s collation, so comparing a column in your database with a column in a &lt;code&gt;#temp&lt;/code&gt; table means comparing two different collations.&lt;/p&gt;
&lt;p&gt;This is the heavy option. Microsoft&apos;s &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/databases/rebuild-system-databases&quot;&gt;rebuild system databases page&lt;/a&gt; says &lt;code&gt;master&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;msdb&lt;/code&gt;, and &lt;code&gt;tempdb&lt;/code&gt; are &quot;dropped and recreated&quot; and that user modifications to them are lost. That means logins, SQL Agent jobs, linked servers, and server-level settings all have to be put back afterward. The lighter alternative is fixing the queries instead. The &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/statements/collations&quot;&gt;&lt;code&gt;COLLATE&lt;/code&gt; clause&lt;/a&gt; supports &lt;code&gt;database_default&lt;/code&gt;, which can make a temp table column use the current database&apos;s collation instead of tempdb&apos;s. The preparation below is all about being able to put back what the rebuild removes.&lt;/p&gt;
&lt;h2&gt;Preparations&lt;/h2&gt;
&lt;p&gt;Run the following queries and copy the results to Notepad or an Excel spreadsheet.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- View system configuration
SELECT * FROM sys.configurations;

-- View server properties
SELECT SERVERPROPERTY(&apos;ProductVersion&apos;) AS ProductVersion,
SERVERPROPERTY(&apos;ProductLevel&apos;) AS ProductLevel,
SERVERPROPERTY(&apos;ResourceVersion&apos;) AS ResourceVersion,
SERVERPROPERTY(&apos;ResourceLastUpdateDateTime&apos;) AS ResourceLastUpdateDateTime,
SERVERPROPERTY(&apos;Collation&apos;) AS Collation;

-- View database files locations
SELECT name, physical_name AS current_file_location
FROM sys.master_files
WHERE database_id IN (DB_ID(&apos;master&apos;), DB_ID(&apos;model&apos;), DB_ID(&apos;msdb&apos;), DB_ID(&apos;tempdb&apos;));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before moving forward, I backed up everything that would be lost due to the rebuild, including SQL logins, jobs, alerts, linked servers, and permissions. I also made sure I had recent backups of every database.&lt;/p&gt;
&lt;h3&gt;SQL Server Configuration&lt;/h3&gt;
&lt;p&gt;To save the current SQL Server settings, I ran the following script and copied the results to a file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Enable advanced options
EXEC sp_configure &apos;show advanced options&apos;, 1;
RECONFIGURE;

DROP TABLE IF EXISTS #ConfigSettings;

-- Create a temp table to store the configuration settings
CREATE TABLE #ConfigSettings (
    name NVARCHAR(35),
    minimum INT,
    maximum INT,
    config_value INT,
    run_value INT,
    config_script NVARCHAR(MAX)
);

-- Insert the configuration settings into the temp table
INSERT INTO #ConfigSettings (name, minimum, maximum, config_value, run_value)
EXEC sp_configure;

-- Update the temp table to add the script column
UPDATE #ConfigSettings
SET config_script = &apos;EXEC sp_configure &apos;&apos;&apos; + name + &apos;&apos;&apos;, &apos; + CAST(config_value AS NVARCHAR(10)) + &apos;;&apos;;

-- Select the results
SELECT * FROM #ConfigSettings;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This script captures the SQL Server settings that will be wiped out during the rebuild. Save the results to a folder dedicated to the rebuild process.&lt;/p&gt;
&lt;h3&gt;Script Logins and Permissions&lt;/h3&gt;
&lt;p&gt;SQL logins need to be exported along with users and roles. I created the two stored procedures from &lt;a href=&quot;https://learn.microsoft.com/en-us/troubleshoot/sql/database-engine/security/transfer-logins-passwords-between-instances&quot;&gt;Microsoft&apos;s page on transferring logins&lt;/a&gt;, then ran this to generate a script that recreates the logins, which I saved to my REBUILD folder:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;EXEC sp_help_revlogin
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Script Msdb Objects&lt;/h3&gt;
&lt;p&gt;Everything in this section is optional, so only do the parts you need. In my case, I needed to copy the SQL Jobs, Alerts, and Operators. If you have Linked Servers, Endpoints, Extended Events, or Database Mail, now is the time to copy those configurations too. I expanded SQL Server Agent in Object Explorer, selected the &lt;code&gt;Jobs&lt;/code&gt; folder, and pressed &lt;code&gt;F7&lt;/code&gt; to bring up Object Explorer Details. From there I could highlight the jobs I wanted and script them as CREATE To a file in my REBUILD folder. I then repeated these steps for the Alerts and Operators.&lt;/p&gt;
&lt;h3&gt;Script Master Objects&lt;/h3&gt;
&lt;p&gt;This section is optional too and depends on how the server is used. I always install &lt;code&gt;sp_WhoIsActive&lt;/code&gt; and similar stored procedures in the &lt;code&gt;master&lt;/code&gt; database. Right-click on the objects you need to keep and script them, individually or all together, to the REBUILD folder.&lt;/p&gt;
&lt;h2&gt;Rebuilding the System Databases&lt;/h2&gt;
&lt;p&gt;Once I completed those steps, I moved on to rebuilding the system databases. This step requires downtime. I logged in to the database server and found the setup.exe for SQL Server, which for me was in &lt;code&gt;C:\Program Files\Microsoft SQL Server\160\Setup Bootstrap\SQL2022&lt;/code&gt;. I opened a CMD prompt, changed to that directory, and ran:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;setup /QUIET /ACTION=REBUILDDATABASE /INSTANCENAME=MSSQLSERVER /SQLSYSADMINACCOUNTS=&amp;lt;ListOfAdminAccounts&amp;gt; /SAPWD=&amp;lt;YourStrongPassword&amp;gt; /SQLCOLLATION=&amp;lt;NewInstanceCollation&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/sql/relational-databases/databases/rebuild-system-databases?view=sql-server-ver16&quot;&gt;Microsoft has a list of additional parameters&lt;/a&gt; you can add to the command.&lt;/p&gt;
&lt;p&gt;After the command completed, I logged back into SQL Server via SSMS and verified the database collation with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT name, collation_name FROM sys.databases WHERE database_id &amp;lt; 5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With that behind me, I was able to run the CREATE scripts I generated earlier, as well as the configuration script.&lt;/p&gt;
&lt;p&gt;I needed to add the following to the top of my sp_configure script to enable Advanced Options.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;EXEC sp_configure &apos;show advanced options&apos;, &apos;1&apos;
GO
RECONFIGURE
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After that was finished, I disabled Advanced Options again.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;EXEC sp_configure &apos;show advanced options&apos;, &apos;0&apos;
GO
RECONFIGURE
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, I restored all databases from the most recent backup, completing the migration.&lt;/p&gt;
</content:encoded></item></channel></rss>