<?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;Despite building dozens of SQL Server availability groups at work, I&apos;ve yet to build one for my homelab. The main reason is that in order to do it right, I needed to set up an Availability Domain, which I have never done before. However, it is currently done, so I was ready to get started.&lt;/p&gt;
&lt;p&gt;This post will mainly only cover standard AGs, meaning I won&apos;t be outlining how to create a Contained AG, but I do plan to write one for that specifically at a different time.&lt;/p&gt;
&lt;p&gt;Some details about my homelab before I begin.&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 each of the IP addresses for the servers, LB, etc... through my Unifi gateway.&lt;/li&gt;
&lt;li&gt;Each of the servers I am running in the lab (Two SQL Servers, one AD) are all running on non-licensed 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;Very obvious, but of course if my storage or host dies, HA won&apos;t really help me since I&apos;m only on one host, but this is just for my homelab and nothing critical will be stored on them so I am accepting the 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 am doing is &lt;strong&gt;two SQL Server 2025 nodes&lt;/strong&gt; in &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 as follows&lt;/p&gt;
&lt;p&gt;VLAN &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, a quick translation: 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;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;ul&gt;
&lt;li&gt;Next, validate that both instances are on the same version of SQL Server, ideally Enterprise Edition, but Standard works ok for this lab as well.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Phase 2 - Active Directory and Domain Join&lt;/h2&gt;
&lt;p&gt;I&apos;ll admit this phase is where I spent most of my time, mostly because I hadn&apos;t stood up a fresh forest, ever. 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; a reverse zone answers &quot;what&apos;s the name for this IP.&quot; It sounds like a nice-to-have, but both SQL Server and Windows clustering actually check reverse lookups in a few places, so skipping this zone can cause odd failures later that have nothing obviously to do with DNS.&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. When researching, I found 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 worth knowing that error exists 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, normally domain-joined machines 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 prompt 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;If you want SQL Server Agent running under the domain identity too, since it isn&apos;t required for anything in this post, only useful if you want scheduled jobs to run as that account, 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;A word on the CNO, since it was confusing to me when I 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;Worth calling out: a default SQL install often has the TCP/IP protocol disabled in favor of Named Pipes, an older way for processes to talk to each other that works fine on a single machine but was never meant for the kind of network traffic an AG listener needs to serve. Ports are just the numbered &quot;doors&quot; a service listens on; 1433 is SQL Server&apos;s normal client door, and 5022 is the door AG replicas use to talk to each other. AG replication traffic over 5022 still works fine without TCP/IP enabled, but clients, and the AG listener itself on 1433, need TCP enabled. If you miss this, SSMS and the failover wizard will 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 already know that Named Pipes was even in the running. The &lt;code&gt;New-NetFirewallRule&lt;/code&gt; lines after it open those two ports through Windows Firewall, since even with TCP enabled inside SQL Server, the OS firewall will silently drop the traffic unless a rule explicitly 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, since automatic seeding needs that starting point to stream from.&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. Certificate-based endpoints are really only necessary when the replicas run under different accounts.&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 automatically, since without that grant, automatic seeding fails silently rather than throwing an error you&apos;d notice.&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, SQL Server&apos;s syntax just 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;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, so that was great.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Witness loss.&lt;/strong&gt; Stopping the file share witness on the DC also does not 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;And &quot;that&apos;s it&quot;.... there are a lot of steps here. I think most shops have a lot more automation that allows for these to be set up much more consistently and faster. Unfortunately I have not set up Ansible for my homelab yet, but the plan is to set this up before my next demo for creating the Contained AG so I don&apos;t need to document all of those steps. Feel free to leave any 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 modify the startup parameters when I 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;As the error suggests, the SQL Server Configuration Manager could not connect to the WMI provider. The Windows Management Instrumentation (WMI) is responsible for reporting the status of the SQL Server services to the Configuration Manager. This should get configured automatically during the install, but sometimes this part of the install can fail to complete, and SQL Server may or may not report it.&lt;/p&gt;
&lt;p&gt;To fix this and regain access to the SQL Server Configuration Manager, you need to recompile the provider using the &lt;code&gt;mofcomp&lt;/code&gt; command.&lt;/p&gt;
&lt;p&gt;I did this by opening an elevated Command Prompt and changing the directory to the SQL Server shared directory for my version. In my case, it was a SQL Server 2022 build, and therefor 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 I ran the command to recompile the provider, 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 only reason I recommend doing this is because apparently the name of the file is different amongst different versions of SQL Server. I saw &lt;code&gt;sqlmgmproviderxpsp2up.mof&lt;/code&gt; used in a different doc, so be on the lookout for that one. Anyway, since SQL Server ships with the .mof file, you should find only one instance in this directory. If nothing shows up here, you&apos;re left with needing to run a repair on the SQL Server installation via Control Panel &amp;gt; Program &amp;gt; SQL Server &amp;gt; Repair&lt;/p&gt;
&lt;p&gt;After identifying the appropriate .mof file, you can finally recompile the provider using the &lt;code&gt;mofcomp&lt;/code&gt; command. 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, but Microsoft&apos;s documentation suggests you need to restart the WMI service for the change to go into effect. So if the issue persists, you can restart the service using this command in PowerShell:&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 should only take a few seconds and it doesn&apos;t impact SQL Server, active connections, or running queries. The only thing at risk is anything polling via WMI at that exact moment. I found some monitoring agents, PowerShell calls, and performance counters all use WMI, so it&apos;s worth doing this during a low-activity window or a maintenance window if you&apos;re doing this on a production server.&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. In my experience, the ideal way to do this is to break the database backup in &quot;parts&quot;, and upload them via an SFTP server or cloud storageanywhere 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 solutions offer functionality to allow for multiple uploads at once. When you&apos;re trying to upload one large file (unless you&apos;re limited by your network connection speeds) you could be waiting 10x or more longer than if you were to upload multiple files 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. This is a unique way to get around those upload size restrictions if your backups exceed them.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The three different ways to accomplish this task are: SQL Server 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;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&apos;re not overly picky about the actual size of the database files and want them backed up to a specific number of files, you can modify the backup job to include &lt;code&gt;@NumberOfFiles = n&lt;/code&gt; where &lt;code&gt;n&lt;/code&gt; equals the number of files you want to backup to.&lt;/li&gt;
&lt;li&gt;If you are picky about the max size of each of the files, you can configure that in the backup job by setting &lt;code&gt;@MaxFileSize = n&lt;/code&gt; where &lt;code&gt;n&lt;/code&gt;, in this case, equals the maximum size of the file 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 permissions to modify the existing database backup commands, so if the backup already exists as a single file, split it after the fact with PowerShell.&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;warning&quot;&amp;gt;
Do not modify the existing/only copy of the production server&apos;s backup. Always duplicate the backup 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 determined solely by &lt;code&gt;$chunkSize&lt;/code&gt;. A larger buffer reduces the number of syscalls and improves throughput on large files: at &lt;code&gt;64MB&lt;/code&gt;, a 1 TB file requires ~16,000 read/write pairs per chunk rather than ~256,000 at &lt;code&gt;4KB&lt;/code&gt;. For small files the difference is negligible. Scale &lt;code&gt;$bufferSize&lt;/code&gt; up for production use; just keep it well below available RAM and no larger than &lt;code&gt;$chunkSize&lt;/code&gt;.
&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; Could be considered more complex 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&apos;s downloads can be found &lt;a href=&quot;https://www.7-zip.org/download.html&quot;&gt;on their 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. It will loop through the directory to find all parts. Because of this, all parts must 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;
</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>Your queries are prisoners. They&apos;re both holding what the other needs, and neither will let go.</description><pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everyone has written about deadlocks, so 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;Neither can communicate with 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 lose.&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, and each window is its own connection. Run each statement one at a time, in the order shown left to right.&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 whats 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 detects this cycle and immediately kills one session with error 1205.&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;Additionally, you can see the deadlocks in the system_health Extended Events deadlock graph:&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 the prisoner&apos;s dilemma drawn by SQL Server. Just two nodes with 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, staying silent becomes the best choice. In SQL Server, that agreement is call &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;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, queries no longer take shared locks, instead they read the last committed version of the row from tempdb. This eliminates the most common class of reader-writer deadlocks without changing any query code. Writers still block writers, but reads stop getting deadlocks entirely. The tradeoff is that tempdb gets more traffic.&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;It requires two things to be enabled before it can be turned on:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Accelerated Database Recovery is a prerequisite
ALTER DATABASE AdventureWorks2022 SET ACCELERATED_DATABASE_RECOVERY = ON;

-- RCSI unlocks 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;On Azure SQL it&apos;s already on by default. On SQL Server 2022 and older it isn&apos;t available.&lt;/p&gt;
&lt;p&gt;Is it the best option? 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 structural cause. Optimized Locking reduces how often that structure becomes a problem.&lt;/p&gt;
&lt;h3&gt;Retry Logic in the Application&lt;/h3&gt;
&lt;p&gt;I actually find this solution to be perfectly fine for most systems, and I see this out in the wild more often than RCSI.&lt;/p&gt;
&lt;p&gt;Since the 1205 error message is something the application can read it&apos;s easy to 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;When SQL Server detects a deadlock, it picks the transaction cheapest to roll back, measured by the amount of transaction log that would need to be undone. You can override that with &lt;code&gt;SET DEADLOCK_PRIORITY&lt;/code&gt;. The range is -10 to 10. The session with the lower value gets killed first. If both sessions have the same priority, SQL Server falls back to log cost.&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 solution but I&apos;ve actually never seen it actually used in production. That&apos;s not to say it&apos;s bad, just that it&apos;s not common.&lt;/p&gt;
&lt;p&gt;The key takaway from all of this is that frequent deadlocks are a sign that something is a problem. Two transactions acquiring the same resources in opposite order will always produce one eventually. Fix the order, eliminate 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 migrate just one year of one table of data from a client&apos;s servers to another SQL Server hosted in a VM in the cloud. The data structure already existed, I just needed to move the data to it. Our problem was that the VM that we were moving into didn&apos;t actually 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; for this since it wouldn&apos;t require me to install anything on the client&apos;s server.&lt;/p&gt;
&lt;p&gt;One thing I ran into before anything else: bcp threw a certificate chain error when connecting to the local instance. The fix is the &lt;code&gt;-u&lt;/code&gt; flag, which tells bcp to trust the server certificate without validating the chain. This requires bcp version 18 or later, so you can run &lt;code&gt;bcp -v&lt;/code&gt; to check. I have it to every command.&lt;/p&gt;
&lt;p&gt;The first thing I did was generate a format file. This 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;You won&apos;t get any output in the CMD window from this if the format file was successfully exported. Once it&apos;s exported you can review the output 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;
One thing to watch is 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. 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, then 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;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;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 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;That&apos;s pretty much it! 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 will warn 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;Preserve 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 independently&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;Executes &lt;code&gt;SET QUOTED_IDENTIFIER ON&lt;/code&gt; -- required when the target table has filtered indexes, indexed views, or computed columns&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 without validating the chain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Full flag reference 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 the process was to kill it from Task Manager. I knew the automation should only typically take a 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;ve 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?view=net-10.0&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 that blocks the current thread for up to the specified number of milliseconds, then returns &lt;code&gt;true&lt;/code&gt; if the process exited within that window or &lt;code&gt;false&lt;/code&gt; if it did not. 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 false. The process is not killed automatically on timeout, so that step is explicit. Note that &lt;code&gt;Kill()&lt;/code&gt; only sends the termination signal, so calling &lt;code&gt;WaitForExit()&lt;/code&gt; immediately after ensures the process has fully stopped before continuing. Call &lt;code&gt;Close()&lt;/code&gt; afterward to release the OS handle.&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;Now its important to note that there are actually two versions of &lt;code&gt;WaitForExit&lt;/code&gt;, one includes a parameter and one does not. The key thing &lt;code&gt;WaitForExit(Int32)&lt;/code&gt; gives you that the no-argument version does not is a decision point. With &lt;code&gt;WaitForExit()&lt;/code&gt;, your script hands control to the process and does not get it back until the process decides to exit. If the process hangs, your script hangs with it, permanently. With &lt;code&gt;WaitForExit(30000)&lt;/code&gt;, your script hands control to the process, but gets it back after 30 seconds regardless. The process is still running at that point, it was not interrupted, you just stopped waiting for it. That is when you decide what to do next. In this case, 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. Your pipeline stops here. With &lt;code&gt;WaitForExit(30000)&lt;/code&gt;, if the script finishes in 5 seconds, the method returns &lt;code&gt;true&lt;/code&gt; after 5 seconds and you hit 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. The process got its window. You decided what happened when it did not use it.&lt;/p&gt;
&lt;p&gt;The no-argument &lt;code&gt;WaitForExit()&lt;/code&gt; does have its place, such as after &lt;code&gt;Kill()&lt;/code&gt;, since you&apos;re not waiting with a timeout but confirming the process is fully gone. What you want to avoid is using it in place of &lt;code&gt;WaitForExit(Int32)&lt;/code&gt; for the timeout case itself, where you need a decision point and a way out if the process hangs.&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 recently encountered a database that consisted of one &lt;code&gt;.mdf&lt;/code&gt; file and seven &lt;code&gt;.ndf&lt;/code&gt; files. Although this configuration is not uncommon in older systems, it is no longer necessary in most modern environments. The workload was not pushing any storage limits, and the underlying disk was modern enough to handle a single data file without issue. Therefore, the objective was to restore the database as-is and then consolidate the files into a single file.&lt;/p&gt;
&lt;p&gt;Historically, SQL Server implementations utilized multiple data files to distribute I/O across spinning disk volumes, which provided performance benefits at the time. However, with the advent of SSDs and SANs, the advantage of using multiple data files is typically smaller or negligible, and the overhead of managing multiple files can outweigh any potential benefits.&lt;/p&gt;
&lt;p&gt;Here&apos;s the approach I used, along with a reproducible lab environment for testing.&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 additional files create unnecessary complexity for backup operations, monitoring, and restore procedures.&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;Now we populate data to ensure files are utilized. This table is designed with a wide structure to distribute pages across 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 provides sufficient data distribution across all files without requiring excessive processing time.&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 restoration (or demo setup), the initial step is to analyze the file layout and actual space utilization per file.&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 environments, I perform several preparatory steps before modifying file structures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ensure recent backups are available and restorable.&lt;/li&gt;
&lt;li&gt;Transition to SIMPLE recovery model if appropriate.&lt;/li&gt;
&lt;li&gt;Schedule operations during periods of minimal I/O impact.&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;In production, I also inform stakeholders that this operation will be resource-intensive and fully logged, even in SIMPLE recovery mode. The database remains online, but &lt;code&gt;EMPTYFILE&lt;/code&gt; operations require significant time.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Move Pages With EMPTYFILE&lt;/h2&gt;
&lt;p&gt;To consolidate files within a filegroup, we systematically migrate all pages from target files to remaining files in the group.&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;The subsequent cleanup command is:&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. It’s fully logged and single-threaded.&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; operates slowly and provides minimal progress feedback in the Messages tab. To monitor operation status, I query &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 environment, this operation may complete quickly depending on row count. For extended testing, increase &lt;code&gt;@TargetRows&lt;/code&gt; in the initial data load to provide more work for &lt;code&gt;EMPTYFILE&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In one production scenario, a ~220 GB NDF required approximately three hours to complete migration. While this operation runs online, I recommend scheduling during maintenance windows to minimize performance impact.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Remove The Emptied File&lt;/h2&gt;
&lt;p&gt;Following successful EMPTYFILE completion, the target file contains no allocated pages and can be removed from the database. After file removal, verify the updated layout:&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 environments, repeat the &quot;EMPTYFILE + REMOVE FILE&quot; sequence for each secondary file scheduled for removal.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Post-Migration Validation&lt;/h2&gt;
&lt;p&gt;Following file consolidation, I perform comprehensive validation:&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 destination files does not accelerate &lt;code&gt;EMPTYFILE&lt;/code&gt; operations. While this prevents autogrowth events, page migration remains single-threaded and fully logged.&lt;/li&gt;
&lt;li&gt;Maintaining FULL recovery model during migration can lead to substantial log growth. In one instance involving a ~400 GB database, the transaction log expanded from ~20 GB to nearly 200 GB before I terminated the operation.&lt;/li&gt;
&lt;li&gt;Performance improvements from file consolidation are typically not measurable in these scenarios. The primary benefit is reduced administrative overhead.&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;To clarify, this approach is not intended for universal &lt;code&gt;.ndf&lt;/code&gt; removal:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TempDB absolutely benefits from multiple files in many environments.&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;File consolidation requires significant time, but this overhead is typically incurred only once. The result is simplified administration with a single data file instead of multiple secondary files, reducing complexity during maintenance and restore operations.&lt;/p&gt;
&lt;p&gt;None of this makes the server magically faster tomorrow. It just reduces operational complexity. If you want to demonstrate this for your team or capture screenshots for documentation, the &lt;code&gt;DemoMergeFiles&lt;/code&gt; database above provides a safe way to show the entire process end-to-end without affecting production systems.&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 almost every SQL Server I work with. Manual installation works fine, but dbatools makes it much easier. If you need to install dbatools first, I have a quick guide 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;Once you have dbatools, installing sp_WhoIsActive is simple:&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;For multiple servers, you can loop through them:&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;This is a simple way to save time and keep sp_WhoIsActive consistent across your servers. It&apos;s straightforward and doesn&apos;t add extra steps to your workflow.&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;We’ve all done it. You hit an error, take one look at the message, and fire it off to someone else. Maybe you drop a screenshot in Teams with a quick “anyone seen this before?” hoping they’ll swoop in with an answer.&lt;/p&gt;
&lt;p&gt;That’s what I call &lt;em&gt;outsourcing your problem&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Asking for help is essential. In fact, it&apos;s fundamental to working in tech, but how you ask matters. When you simply forward the problem, you&apos;re asking someone else to start from scratch. They don&apos;t know your context, what changed, or what you&apos;ve already investigated. They have to repeat all the discovery work you skipped.&lt;/p&gt;
&lt;p&gt;If you want effective help, provide context. Read the error message. Try to understand what it&apos;s communicating. Even if you don&apos;t make much progress, demonstrating effort changes the dynamic completely. Compare these approaches:&lt;/p&gt;
&lt;p&gt;“Anyone know how to fix this?”
vs.
“I ran X and got this error. It looks like Y is failing, but I’m not sure why. Can you help me read it?”&lt;/p&gt;
&lt;p&gt;The second approach shows you&apos;ve engaged with the problem. It gives others a starting point. They&apos;ll respond more quickly, and you&apos;ll gain knowledge instead of just copying a solution.&lt;/p&gt;
&lt;p&gt;Effective troubleshooting begins with ownership. Most people don&apos;t expect you to have all the answers, but they do expect you to care enough to investigate what&apos;s happening before requesting assistance.&lt;/p&gt;
&lt;p&gt;So before you send that screenshot, pause. Read the error. Consider what changed. Attempt to isolate the issue.&lt;/p&gt;
&lt;p&gt;Then, if you&apos;re still stuck, ask for help &lt;em&gt;with context&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I speak from experience. I&apos;ve sent countless screenshots hoping someone else would save me five minutes of reading. It never worked. The most valuable solutions I&apos;ve learned came from taking time to understand what the system was actually telling me. Once I stopped outsourcing my problems, I became better at solving them.&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 about SQL Server automation often, and dbatools comes up frequently. Instead of rewriting install steps in every post, I&apos;m putting them here once so I can just link back.&lt;/p&gt;
&lt;p&gt;The full instructions are on the official site: &amp;lt;a href=&quot;https://dbatools.io/download&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;dbatools.io/download&amp;lt;/a&amp;gt;. If you&apos;re new to dbatools, follow those first—they&apos;re always current. You should also check out their Getting Started page: &amp;lt;a href=&quot;https://dbatools.io/getting-started/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;dbatools.io/getting-started&amp;lt;/a&amp;gt; while you&apos;re there since it has many great examples to give you ideas for your next automation project.&lt;/p&gt;
&lt;p&gt;The process is simple. From an admin PowerShell session:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Install-Module dbatools -Scope CurrentUser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you&apos;re prompted about trusting the repository, say yes... it&apos;s the official source. On older systems you may also need to explicitly trust PSGallery with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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;Keep it updated periodically with this command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Update-Module dbatools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you get errors about script execution policy, you may need to allow PowerShell to run downloaded modules. The simplest way is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;pre&gt;&lt;code&gt;Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That lets you run modules you install from the Gallery while still keeping protections in place.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That&apos;s all you need for most cases. For older Windows versions or locked-down environments, see alternate installation methods on the &amp;lt;a href=&quot;https://dbatools.io/download&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;official download page&amp;lt;/a&amp;gt;.&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 the FTP service is set up on an Ubuntu Server VM. The same steps apply whether you&apos;re using bare metal or another hypervisor.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Create the VM&lt;/h2&gt;
&lt;p&gt;Install Ubuntu Server LTS and assign it a static LAN IP for easier access. 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&apos;t be reached from another machine, check the network adapter mode and verify the IP address is in the correct subnet.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Update the OS&lt;/h2&gt;
&lt;p&gt;Update the system before installing additional software.&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;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&apos;t appear to be running, check its status 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 created a dedicated account for FTP traffic to keep it 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;Login failures (&lt;code&gt;530 Login incorrect&lt;/code&gt;) often indicate the account is disabled in &lt;code&gt;/etc/ftpusers&lt;/code&gt; or &lt;code&gt;local_enable&lt;/code&gt; isn&apos;t set in the configuration.&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; requires that a chroot directory cannot be writable. To work around this, I created a locked root folder with a writable subdirectory for uploads.&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 incorrect. 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 configuration file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nano /etc/vsftpd.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here&apos;s the configuration 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;ul&gt;
&lt;li&gt;If directory listings stall, the passive port range may not be 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 configuration.&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 to start 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 start, 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 port 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 take effect. 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 testing with 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 configuration.&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&apos;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 implemented extensive hardening for internet exposure. Network security isn&apos;t my specialty, so I stick to basic configurations. If you have suggestions for improving security, either for FTP specifically or my home lab in general, I&apos;d appreciate hearing 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>What the Monty Hall problem taught me about questioning assumptions, conditional probability, and diagnosing SQL Server performance.</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;I used to think probability puzzles were mainly for math enthusiasts. The Monty Hall problem, in particular, seems designed to be counterintuitive. But the more time I spent analyzing SQL Server execution plans, the more familiar it became. It turns out Monty Hall and SQL Server share something important: both make you think you&apos;re getting a fair choice when the odds were actually skewed before you began.&lt;/p&gt;
&lt;h2&gt;What’s Monty Hall again?&lt;/h2&gt;
&lt;p&gt;You’re on a game show. There are three doors. Behind one: a car. Behind the other two: goats. You pick a door. Monty, the host, opens one of the remaining doors to reveal a goat. He offers you a choice: stick with your original door or switch.&lt;/p&gt;
&lt;p&gt;Your instinct says, &quot;It’s 50/50 now.&quot; But it’s not. The math says switching gives you a 2/3 chance of winning. Staying only gives you 1/3. It’s weird, but it’s true, they even proved it on MythBusters.&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;SQL Server does the same thing&lt;/h2&gt;
&lt;p&gt;The simplified version: SQL Server picks a door (an execution plan) based on what it knows at compile time. It makes a choice. Then it shows you the result: the plan it developed based on that initial information.&lt;/p&gt;
&lt;p&gt;But the plan it picked might be based on incomplete information. Or a rare parameter. Or outdated statistics. SQL Server isn&apos;t being deceptive. It&apos;s optimizing based on the information available at that moment.&lt;/p&gt;
&lt;p&gt;Let’s demo it.&lt;/p&gt;
&lt;h2&gt;Demo: Parameter Sniffing and Bad Optimizer Assumptions&lt;/h2&gt;
&lt;p&gt;Set up this demo in a test database.&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;&amp;lt;Callout type=&quot;info&quot;&amp;gt;
You now have a skewed table: Common shows up ~97% of the time. SQL Server will act very differently depending on which value it sees first.
&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;Now that a plan is cached for &apos;RareA&apos;, we’ll run it again but with &apos;Common&apos;. Below is the execution plan I received.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2025-07-22-144325.0y71bfuF_Z2oaEs.webp&quot; alt=&quot;RareA execution plan&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Run with common value — this is the “wrong” plan reused
EXEC dbo.SniffMonty @Category = &apos;Common&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What&apos;s happening is that SQL Server saw &apos;RareA&apos; first, built a plan that works well for a small result set, and then continued using it. When we run it again with &apos;Common&apos;, it reuses that same plan, even though it now needs to scan almost the entire table. This results in nested loops performing thousands of key lookups, and performance degrades significantly. The plan isn&apos;t incorrect, it&apos;s based on an outdated assumption.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2025-07-22-144325.0y71bfuF_Z2oaEs.webp&quot; alt=&quot;RareB execution plan&quot; /&gt;
&lt;img src=&quot;https://chandlergray.com/_astro/screenshot-2025-07-22-144342.B-SyPeCw_bErBc.webp&quot; alt=&quot;RareB execution plan 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;SQL Server generated an execution plan that estimated only 1,000 rows, when it actually needed to return 98,000 rows.&lt;/p&gt;
&lt;h3&gt;Step 3: Re-run it 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;Roles reversed execution plan&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;info&quot;&amp;gt;
Now the plan is optimized for the common case. Running it with &apos;RareA&apos; wastes resources reading way too much.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;p&gt;It&apos;s not about right versus wrong. It&apos;s about how SQL Server makes its decision before you know what parameters will be used.&lt;/p&gt;
&lt;p&gt;The optimizer&apos;s &quot;door&quot; isn&apos;t random, it&apos;s conditional. This is the Monty Hall connection. In the game show, Monty never opens the winning door. Up to this point I&apos;ve been referring to this as luck, but it&apos;s actually knowledge. Once he opens a goat door, the odds change.&lt;/p&gt;
&lt;p&gt;In SQL Server, the optimizer sees parameter values, statistics, and cost estimates, then selects one execution plan. What it shows you isn&apos;t a 50/50 choice. It&apos;s based on what it considers most likely. And that cached assumption persists until you force it to change.&lt;/p&gt;
&lt;h2&gt;Solutions and Strategies&lt;/h2&gt;
&lt;p&gt;Sometimes we stick with the plan and get poor performance. There isn&apos;t a perfect solution to this problem, but there are several strategies to mitigate it.&lt;/p&gt;
&lt;p&gt;Use OPTION (RECOMPILE) to force a new plan for each execution. This approach avoids reusing a problematic plan, but it can be resource-intensive if the procedure is called frequently.&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;Other options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use &amp;lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query?view=sql-server-ver17#optimize-for-unknown&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&amp;gt;OPTION (OPTIMIZE FOR UNKNOWN)&amp;lt;/a&amp;gt; if you want SQL Server to make a generic plan.&lt;/li&gt;
&lt;li&gt;Use OPTIMIZE FOR (&apos;Common&apos;) if you know most values will be skewed that way. This option does not scale well, however.&lt;/li&gt;
&lt;li&gt;Consider splitting procedures for hot vs cold values, or use dynamic SQL to avoid plan reuse where it hurts.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Key Takeaway: Question the First Plan&lt;/h2&gt;
&lt;p&gt;The optimizer&apos;s job is to make an educated guess. Your job is to determine whether that guess is appropriate. Sometimes it isn&apos;t. Sometimes it&apos;s based on outdated statistics, parameter sniffing, or other factors.&lt;/p&gt;
&lt;p&gt;When you encounter a poor execution plan, don&apos;t assume it&apos;s optimal for your use case. Ask what it was designed for. Ask whether the system made assumptions based on limited information. And when it does... consider switching approaches.&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;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 is useful for side-by-side shape checks but not useful as a scale metric 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 does not 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
l
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 many SQL Server databases. Different teams, different eras, different ideas of what &quot;good enough&quot; looks like. What 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 mentioned why autogrowth matters: uncontrolled growth leads to excessive VLF counts, which hurts performance.&lt;/p&gt;
&lt;p&gt;I often take over databases that are 10+ years old, with no history, no documentation, and no log monitoring. There&apos;s nobody around to ask &lt;em&gt;why&lt;/em&gt; things are set the way they are. So, unless there&apos;s evidence to the contrary, I take the same approach every time: standardize autogrowth to 1024MB for every user database.&lt;/p&gt;
&lt;h2&gt;The Script&lt;/h2&gt;
&lt;p&gt;Here&apos;s 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 just makes future growth happen in predictable, healthy chunks.&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 DBCC SHRINKFILE, so you run it. It seems to work, or does nothing. Either way, the log eventually grows back, your I/O performance suffers, and the problem returns a week later.&lt;/p&gt;
&lt;p&gt;Shrinking your transaction log normally isn&apos;t a solution. It&apos;s a temporary 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;This command doesn&apos;t just cut off the end of the file and return space. SQL Server moves virtual log files (VLFs) toward the front, then releases unused space from the end of the file—but only if those VLFs are inactive.&lt;/p&gt;
&lt;p&gt;If they&apos;re 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. It just won&apos;t shrink. And if you&apos;re in full recovery mode without frequent log backups, SQL Server can&apos;t even mark old log space as reusable.&lt;/p&gt;
&lt;h2&gt;Reproducing the Problem&lt;/h2&gt;
&lt;p&gt;Here&apos;s a simple demo 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 data safe—and that means holding onto log space.&lt;/p&gt;
&lt;h2&gt;Why Regular Shrinking Could* Make Things Worse&lt;/h2&gt;
&lt;p&gt;Shrinking isn&apos;t entirely harmless. Every time you shrink the log, you break up its internal structure. On the next autogrowth, SQL Server often creates many small VLFs to keep up—leading to slower crash recovery and worse performance.&lt;/p&gt;
&lt;p&gt;Also, shrinking hides issues like no log backups, inefficient transaction patterns, bad autogrowth settings, or logs 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 correctly from the start. Look at historical peak usage and give your log file enough room.&lt;/li&gt;
&lt;li&gt;Use fixed autogrowth sizes—avoid percentages. Percentage-based growth leads to unpredictable VLF counts.&lt;/li&gt;
&lt;li&gt;Schedule frequent log backups (for full or bulk-logged recovery). This marks inactive VLFs as reusable.&lt;/li&gt;
&lt;li&gt;Monitor log space properly so you know what&apos;s happening 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;Let&apos;s say you just offloaded a massive archive table or ran a one-time migration and your log grew much larger than your workload needs. In that case, a one-time shrink is fine. But after that, resize the file to something appropriate and let it grow only when necessary.&lt;/p&gt;
&lt;p&gt;If you&apos;re running scheduled log shrinks in a job, it&apos;s time to review 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 trying to tell you something: your recovery model, backup frequency, or workload needs attention.&lt;/p&gt;
&lt;p&gt;Don&apos;t treat &lt;code&gt;DBCC SHRINKFILE&lt;/code&gt; like regular maintenance. Treat it like a fire extinguisher: use it only when you absolutely have to, and figure out how to prevent 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 SSRS ReportServer databases often looks simple on paper: copy a &lt;code&gt;.bak&lt;/code&gt; file, run RESTORE, and expect everything to work perfectly. But the reality is different—encryption keys, service accounts, and user mappings can create serious headaches if you don&apos;t handle them properly. I&apos;ve seen migrations fail because the new database couldn&apos;t decrypt connection strings or users got locked out due to SID mismatches.&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. Use a strong password and store both file and password in a secure location.&lt;/p&gt;
&lt;h2&gt;Back Up the ReportServer Database&lt;/h2&gt;
&lt;p&gt;With the encryption key secured, perform 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 confirm that the destination instance has its own fresh ReportServer backup in case you need to roll back.&lt;/p&gt;
&lt;h2&gt;Restore the Database on the Destination&lt;/h2&gt;
&lt;p&gt;I always switch the ReportServer database to 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;Immediately check &lt;code&gt;sys.databases&lt;/code&gt; or the SQL Server error log to ensure 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 the &lt;code&gt;sa&lt;/code&gt; account 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;Validate that each database user now correctly maps to its server login.&lt;/p&gt;
&lt;h2&gt;Recreate the SSRS Service Account User&lt;/h2&gt;
&lt;p&gt;The SSRS service account (usually &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, verify that a simple query against an SSRS catalog view (e.g., &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.&lt;/p&gt;
&lt;p&gt;&amp;lt;Callout type=&quot;info&quot;&amp;gt;
The backup/restore process does &lt;strong&gt;not&lt;/strong&gt; migrate shared data source definitions. After migration, you must manually recreate each shared data source in the new SSRS instance and reconfigure every report to use the appropriate data source.
&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 URL in a browser. Navigate through several folders and render a few reports. If you encounter a 404, authentication failure, or rendering error, inspect the SSRS service log (&lt;code&gt;ReportServerService__*.log&lt;/code&gt;) for clues—whether it’s database connectivity, decryption, or URL binding—and correct the underlying issue before retesting.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;By exporting and restoring the encryption key first, verifying each database-restore step, fixing ownership and logins, recreating the SSRS service-account user, and then restoring keys, you avoid the usual surprises. I&apos;ll eventually wrap these steps into a PowerShell module, but for now the process is straightforward enough and really 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 clear that SQL Server doesn&apos;t run natively on macOS. I learned this the hard way in college. I bought a Windows license, set up a VirtualBox VM on my 8GB MacBook Pro, and struggled through installing SQL Server 2016 for a database class. It was a painful experience.&lt;/p&gt;
&lt;p&gt;If only 2015 me 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 lighter, more efficient option. Instead of virtualizing hardware and running a full OS for each instance, containers share the host system&apos;s kernel and isolate the app at the process level. For our purposes, 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 types: Intel and Apple Silicon. Docker Desktop supports both, but setup steps are a bit different. 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 choose the version that matches your architecture.&lt;/p&gt;
&lt;p&gt;After installing, open Docker and sign in or create a new account.&lt;/p&gt;
&lt;h2&gt;Install SQL Server&lt;/h2&gt;
&lt;p&gt;Deploying SQL Server is surprisingly easy, which is why it&apos;s better than a VM for this use case. Just open Terminal and run these commands:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If that fails with -bash: docker: command not found, Docker might 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 online for help. Once those issues are fixed, run this command to pull SQL Server 2022:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker pull mcr.microsoft.com/mssql/server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When that finishes, run this to start SQL Server. Change the password before running:&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 should complete quickly, and when it&apos;s done, you have SQL Server 2022 running on your local machine on port 1433.&lt;/p&gt;
&lt;h2&gt;What now?&lt;/h2&gt;
&lt;p&gt;This setup isn&apos;t for production, but it&apos;s great for local testing, development, and learning. If you want to explore more, check out Docker Hub and the official Microsoft SQL Server on Linux documentation.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://hub.docker.com/r/microsoft/mssql-server&quot;&gt;Docker&apos;s Documentation&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?view=sql-server-ver16&amp;amp;tabs=cli&amp;amp;pivots=cs1-bash&quot;&gt;Microsoft&apos;s Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&apos;s it: SQL Server on your Mac, no VM, no problem. If you made it this far, congratulations! You&apos;ve bypassed 2015 me and saved yourself 4GB of RAM and some sanity.&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 optimized for SQL Server 2022. While it may work with other versions, some wait types specific to 2022 will be missing.
&amp;lt;/Callout&amp;gt;&lt;/p&gt;
&lt;p&gt;Analyzing wait statistics quickly reveals where the server is experiencing performance issues. SQL Server is not particularly effective at communicating its problems, nor does it self-correct.&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;Memorizing all 900+ wait types in SQL Server is impractical, so filtering out irrelevant noise is valuable. By querying &lt;code&gt;sys.dm_os_wait_stats&lt;/code&gt; and categorizing hundreds of wait types into meaningful groups, we can focus on significant waits and ignore benign background activity.&lt;/p&gt;
&lt;p&gt;When inheriting a server, preserve existing wait statistics to understand historical performance patterns. Clear old data only when troubleshooting active incidents and requiring a fresh baseline. For live investigations, execute:&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 categorizes each wait into CPU, Memory, Database Blocking, I/O, or Tempdb Contention groups while filtering noise, then uses a single GROUPING SETS query to display both detailed and aggregated results efficiently.&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;After execution, rows labeled &lt;code&gt;Detailed&lt;/code&gt; display individual wait types being tracked, while &lt;code&gt;Aggregated&lt;/code&gt; rows provide category summaries. Use detailed output for investigation and aggregated results for a high-level overview of where SQL Server spends most time.&lt;/p&gt;
&lt;p&gt;Avoid focusing solely on the top wait type. Total waits across categories often provide different insights than individual peaks. Analyze the aggregates, not just the maximum values.&lt;/p&gt;
&lt;p&gt;For reference, the benign wait_types list is sourced from &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;, query 42.&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 began learning SQL Server administration, I researched numerous articles about configuring Max Degree of Parallelism (MAXDOP). MAXDOP controls the number of logical processors a single parallel plan can utilize. A setting of 0 allows SQL Server to use all available processors (up to 64). A setting of 1 forces each query to execute on a single thread. Intermediate values allocate a predictable portion of CPU capacity to each parallel operator.&lt;/p&gt;
&lt;h2&gt;How to set MAXDOP&lt;/h2&gt;
&lt;p&gt;Despite extensive information available, conflicting recommendations about MAXDOP configuration are common. To establish clarity, I rely on Microsoft&apos;s official guidance. While this may not be universally optimal, it provides a solid foundation. Below 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;For simplified implementation, I developed a T-SQL script that provides recommendations automatically:&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 results are straightforward. Use the recommended value from the script and apply it with the following configuration:&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;Monitoring MAXDOP effectiveness requires careful observation. Since MAXDOP changes impact query performance, when should adjustments be made? My approach is to measure performance of both slow and reasonably fast queries. Apply Microsoft&apos;s recommended MAXDOP setting, then monitor overall performance over subsequent hours, days, and weeks. If queries exhibit slower performance, examine the query plans. Indicators of excessive MAXDOP include queries that previously ran efficiently with minimal threads now utilizing more threads without performance improvement, or increased execution wait times. Conversely, insufficient MAXDOP manifests as queries constrained to single threads when they would benefit from parallelism, or elevated CPU wait statistics. Adjust settings based on observed performance patterns.&lt;/p&gt;
&lt;h2&gt;Using Query OPTION (MAXDOP n)&lt;/h2&gt;
&lt;p&gt;Database administrators or developers might use the &lt;code&gt;OPTION (MAXDOP n)&lt;/code&gt; query hint to enforce specific parallelism for individual queries. This practice is generally not recommended for production environments, but can be valuable for performance testing and troubleshooting. Here&apos;s an example of this 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;Database-level MAXDOP settings may not be optimal for every query, making query-level hints necessary in some cases to meet performance requirements. However, use this approach sparingly and maintain awareness of queries using hints. When SQL Server configurations change, adjusting at the database level is more efficient than locating and modifying individual queries.&lt;/p&gt;
&lt;p&gt;Every environment has unique characteristics. Begin with Microsoft&apos;s recommendations and adjust based on performance requirements. Continuous monitoring and measurement are essential to prevent performance issues that require emergency intervention.&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;I once faced a 30GB SQL Server backup through a VPN that managed about 1MB/s and dropped every 45-60 minutes. My first thought was, &quot;If I split this into thirty 1GB pieces and copy them in parallel, maybe I can outrun 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 speeds next. At about 1MB/s per slice, copying took roughly 15-17 minutes each. 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 overall transfer time stayed about the same: moving 30GB at 1MB/s still takes about eight hours, plus some 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 few slices with various &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 about half an hour, and &lt;code&gt;/MT:8&lt;/code&gt; came out on top. With that insight, I started 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;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 through the afternoon and evening. By day&apos;s end all 30 parts had arrived successfully.&lt;/p&gt;
&lt;p&gt;Finally, to restore the original .bak file once all parts have transferred, you can unchunk them with a simple 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 SQL Server at &lt;code&gt;restored-large.bak&lt;/code&gt; when running your RESTORE DATABASE command.&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 for use.&lt;/p&gt;
&lt;p&gt;Today I learned that chopping a large file into pieces rarely speeds up the total transfer. Instead, tuning Robocopy&apos;s &lt;code&gt;/MT&lt;/code&gt; setting and running multiple instances gives you a straightforward, resilient workflow. When your VPN inevitably drops, you&apos;ll appreciate that you let a tested tool handle 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;I often encounter scenarios where query tuning involves more than just speed. It&apos;s also about understanding how SQL Server processes data and which patterns yield the best results. Recently, I explored how different query patterns for 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; to measure the impact.&lt;/p&gt;
&lt;h2&gt;Setup&lt;/h2&gt;
&lt;p&gt;For this analysis, I retrieved user activity from the StackOverflow database&apos;s &lt;code&gt;Users&lt;/code&gt; table, specifically for the year 2017 (since my database only contains data through 2018). With millions of rows in the table, this provided a good test case. There is a non-clustered index covering this query, which I&apos;ve included below for reference. The &lt;code&gt;LastAccessDate&lt;/code&gt; column is a datetime type.&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 and their results:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;First, I used &lt;code&gt;BETWEEN&lt;/code&gt; with precise timestamps. This is a common method I see in practice.&lt;/li&gt;
&lt;/ol&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;ol&gt;
&lt;li&gt;Next, I used &lt;code&gt;&amp;gt;=&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt; with precise timestamps. This approach is also fairly common.&lt;/li&gt;
&lt;/ol&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;So far, the results were expected. Both queries run quickly enough for my needs and return the expected data. Let&apos;s continue.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Then I tried &lt;code&gt;BETWEEN&lt;/code&gt; again, but without the precise timestamps. I wanted to see if using a less precise date format would make the query non-sargable.&lt;/li&gt;
&lt;/ol&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;I didn&apos;t see any significant performance degradation in terms of speed or reads, but I did get a few thousand fewer rows than in the previous tests. We&apos;ll discuss that later.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;After that, I applied the same approach using &lt;code&gt;&amp;gt;=&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt;. The results were similar to the previous tests.&lt;/li&gt;
&lt;/ol&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;ol&gt;
&lt;li&gt;Next, I used &lt;code&gt;BETWEEN&lt;/code&gt; again, but this time converting the datetime value to VARCHAR to manipulate the date format.&lt;/li&gt;
&lt;/ol&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;ol&gt;
&lt;li&gt;Finally, I tried the same approach with &lt;code&gt;&amp;gt;=&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt;, converting the datetime value to VARCHAR as I did in step 5.&lt;/li&gt;
&lt;/ol&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;h2&gt;Key Takeaways&lt;/h2&gt;
&lt;p&gt;The results show that logical reads increase when using CONVERT or similar formatting functions. This happens because they make queries non-sargable, leading to &lt;strong&gt;table scans&lt;/strong&gt; instead of &lt;strong&gt;index seeks&lt;/strong&gt;. Additionally, simplified date ranges using &lt;code&gt;&amp;gt;=&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt; perform better than &lt;code&gt;BETWEEN&lt;/code&gt; due to better clarity and consistency, especially when dealing with time boundaries. The fewer rows returned in Query 3 highlight the importance of precise filtering logic. Based on these results, I&apos;ll be avoiding CONVERT and BETWEEN on datetime columns going forward.&lt;/p&gt;
&lt;p&gt;Since I&apos;m writing this on December 31st, 2024, I want to wish everyone a happy New Year! Thank you for reading!&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 frequently use &lt;a href=&quot;https://glennsqlperformance.com/resources/&quot;&gt;Glenn Berry&apos;s SQL Server DMV queries&lt;/a&gt; for performance tuning and diagnostics. Because I run these queries often, I needed a way to quickly connect to a server, collect the necessary data, and review it later when time permits. In my role, efficiency and accuracy are essential, so I automate repetitive tasks whenever possible.&lt;/p&gt;
&lt;p&gt;While Glenn Berry&apos;s scripts are well-documented—and I recommend running them manually to understand their context and importance—I wanted to streamline the process. I developed a PowerShell script that automates these diagnostic queries, enabling faster data collection that can be reviewed when time allows. This approach is also valuable for real-time troubleshooting.&lt;/p&gt;
&lt;h2&gt;What Is dbatools?&lt;/h2&gt;
&lt;p&gt;Before diving into the script, I want to introduce &lt;a href=&quot;https://dbatools.io/&quot;&gt;dbatools&lt;/a&gt;, a comprehensive PowerShell module for DBAs and data professionals. This module includes numerous functions for monitoring, scripting, and management tasks. While I plan to cover dbatools in more detail in future posts, I encourage you to explore 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; to get started.&lt;/p&gt;
&lt;h2&gt;The Script&lt;/h2&gt;
&lt;p&gt;Credit goes to the maintainer of the core functionality, the &lt;a href=&quot;https://docs.dbatools.io/Invoke-DbaDiagnosticQuery&quot;&gt;Invoke-DbaDiagnosticQuery&lt;/a&gt; command in the dbatools repository. The PowerShell script I developed automates SQL Server diagnostic data collection using Glenn Berry&apos;s DMV queries via Invoke-DbaDiagnosticQuery. It connects to a specified SQL Server instance, runs instance-level and optional database-specific queries, exports results to CSV files, and compiles them into a single Excel workbook for analysis.&lt;/p&gt;
&lt;h3&gt;Accessing the Script&lt;/h3&gt;
&lt;p&gt;The script is available in &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;. You can download it directly, and updates will be maintained there.&lt;/p&gt;
&lt;h3&gt;Prerequisites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;PowerShell Execution Policy: Ensure that your system&apos;s execution policy allows the running of scripts. You can check your current policy by running &lt;code&gt;Get-ExecutionPolicy&lt;/code&gt; in PowerShell. For more information on execution policies and how to modify them, refer to Microsoft&apos;s official documentation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Required Modules: The script will attempt to automatically install the necessary modules (dbatools and ImportExcel), but it&apos;s good practice to install them manually to avoid 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;Here&apos;s a step-by-step guide to using the script:&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 &lt;code&gt;Export-SQLDiagnosticData.ps1&lt;/code&gt; script to a local directory.&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 (e.g., &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 bypass most of the prompts by simply specifying them in the execution of the script. For example:&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;As always, I welcome feedback on this script. If you have suggestions for improvements, please comment below so I can consider adding 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;I&apos;ve been simplifying my performance tuning approach. One method I&apos;ve used is finding exact duplicate indexes. Duplicate indexes on a table are problematic because whenever you insert, update, or delete a record on that table, SQL Server needs to update the same index multiple times.&lt;/p&gt;
&lt;h2&gt;Why Duplicate Indexes Are Problematic&lt;/h2&gt;
&lt;p&gt;Duplicate indexes are indexes that have the same key columns AND the same included columns as another index on the same table.&lt;/p&gt;
&lt;p&gt;Issues caused by duplicate indexes include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Increased Storage Space&lt;/strong&gt;: Each index consumes disk space. Duplicate indexes unnecessarily inflate the size of your database.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Slower Write Operations&lt;/strong&gt;: &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; operations become slower because the database engine must update the duplicate indexes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Longer Maintenance Windows&lt;/strong&gt;: Operations like index rebuilds and updates take longer, impacting maintenance tasks and system availability.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Script&lt;/h2&gt;
&lt;p&gt;I use this SQL query to find exact duplicate indexes in a database. The query is straightforward when you run it, but I&apos;ll break it down so you can understand 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 basic information about existing indexes:&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 key columns for each index, including sort order (ASC/DESC). The &lt;code&gt;STUFF&lt;/code&gt; function removes the leading comma and space from the concatenated string. I use a subquery to concatenate column names and their sort orders.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;FOR XML PATH(&apos;&apos;)&lt;/code&gt; trick is used to concatenate the results into a single string.&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 like 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 generates the &lt;code&gt;DROP INDEX&lt;/code&gt; statement. It doesn&apos;t help identify duplicates, but it&apos;s convenient to have.&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;Finally, this section identifies the actual duplicates. The main query finds indexes that have duplicates based on their key columns and included columns. To do this, the query joins &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;: By confirming that the index names are different (&lt;code&gt;d1.IndexName &amp;lt;&amp;gt; d2.IndexName&lt;/code&gt;), we avoid comparing an index with itself, ensuring that we&apos;re only identifying true duplicates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same Schema and Table&lt;/strong&gt;: Ensuring both &lt;code&gt;d1&lt;/code&gt; and &lt;code&gt;d2&lt;/code&gt; have the same schema and table names confirms that we&apos;re comparing indexes within the same table, keeping our focus precise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same Key Columns&lt;/strong&gt;: We check if the key columns are identical (&lt;code&gt;d1.KeyColumnList = d2.KeyColumnList&lt;/code&gt;), which helps us identify indexes that have the same structure and potentially serve the same purpose.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same Included Columns&lt;/strong&gt;: The query compares the included columns, handling any &lt;code&gt;NULL&lt;/code&gt; values with the &lt;code&gt;ISNULL&lt;/code&gt; function (&lt;code&gt;ISNULL(d1.IncludeColumnList, &apos;&apos;) = ISNULL(d2.IncludeColumnList, &apos;&apos;)&lt;/code&gt;). This ensures that indexes aren&apos;t mistakenly identified as different due to missing data.&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 several things this query doesn&apos;t check for, so be careful when dropping any index. When deciding which index to drop, it&apos;s best to drop the one with fewer reads. You can find that with this query:&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;This query also doesn&apos;t check for things like whether it&apos;s a unique index, compression, fill factor, among others.&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; Don&apos;t ruin your Friday.&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;I&apos;ve been working on simplifying my performance tuning approach lately. One effective way to improve query performance is eliminating key lookups. A key lookup happens when SQL Server needs to get columns that aren&apos;t in a non-clustered index, so it has to go to the clustered index or table for the missing data. This creates extra I/O operations, which usually means slower queries and more server load.&lt;/p&gt;
&lt;h2&gt;Investigating the I/O Performance&lt;/h2&gt;
&lt;p&gt;Since I&apos;m focused on I/O, I start with &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 see if the optimizer suggests any indexes.&lt;/p&gt;
&lt;p&gt;Let&apos;s look at this I/O-heavy query:&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; results show:&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 important number here is 320,359 logical reads. After checking, I see this table only has the clustered index on the primary key. The execution plan suggests creating this index:&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 run the query again. The results already show 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. The problem is key lookups - &lt;code&gt;UpVotes&lt;/code&gt; and &lt;code&gt;DownVotes&lt;/code&gt; are being fetched separately because they&apos;re not in the index.&lt;/p&gt;
&lt;h2&gt;Including Columns&lt;/h2&gt;
&lt;p&gt;Since &lt;code&gt;UpVotes&lt;/code&gt; and &lt;code&gt;DownVotes&lt;/code&gt; aren&apos;t in the &lt;code&gt;WHERE&lt;/code&gt; clause, I&apos;ll add them to the index&apos;s &lt;code&gt;INCLUDE&lt;/code&gt; clause:&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;We run the query one more time and get these results:&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;By eliminating key lookups, we cut logical reads from 320,359 to just 32. Remember 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. Use them carefully and create covering indexes where they make sense. 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 regularly releases updates to SQL Server to fix issues and improve functionality. These updates typically come as Cumulative Updates (CUs), which release about every 30 days. Here are the steps to download and install the latest CU for your SQL Server.&lt;/p&gt;
&lt;h2&gt;Download the Latest CU&lt;/h2&gt;
&lt;p&gt;To get the latest cumulative update for your SQL Server version, go to &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 official release page&lt;/a&gt;. This page has updates for all supported SQL Server versions.&lt;/p&gt;
&lt;p&gt;Each cumulative update works with a specific SQL Server version (like SQL Server 2019 or 2022), so check your version first by running this query in SSMS:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT @@VERSION
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the release page, pick your SQL Server version and you&apos;ll go to the CU page for that specific KB. Read the CU documentation, then scroll down to the section called &lt;code&gt;How to obtain or download this or the latest cumulative update package&lt;/code&gt; and click the link that says &lt;code&gt;Download the latest cumulative update package for SQL Server 20xx now&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You&apos;ll go to another page. When it loads, download the right CU file. The file will be a &lt;code&gt;.exe&lt;/code&gt; file 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 CUs requires downtime for your SQL Server, so schedule a maintenance window before you start. Most importantly, &lt;strong&gt;make sure you have a recent database backup before installing the CU&lt;/strong&gt;. This can turn a 15-minute maintenance task into a 3-hour emergency call with your boss and angry clients. Once you&apos;re ready, find the &lt;code&gt;.exe&lt;/code&gt; file and right-click it to &lt;strong&gt;Run as Administrator&lt;/strong&gt;. This starts 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 License Terms&lt;/strong&gt;: Read and accept the license agreement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Select SQL Server Instances&lt;/strong&gt;: The installer finds all SQL Server instances on your machine. Choose which instance(s) to update.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File Check&lt;/strong&gt;: The installer checks that needed files 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 information 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 setup. During this time, the selected instances might be unavailable, so users will start complaining if you didn&apos;t warn them about downtime.&lt;/p&gt;
&lt;p&gt;Make sure all operations show &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;Some people suggest restarting SQL Server after updates, but I only do this if the installer specifically asks for it. To check that the update worked, open SSMS, connect to your instance, and run the query again:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT @@VERSION
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you should see the CU short code after the version number and the KB (for example: &lt;code&gt;Microsoft SQL Server 20xx (RTM-CUxx) (KBxxxxxxx)...&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;If the installation fails and you need to rollback, you can find uninstall instructions for your specific KB at the bottom of that KB article.&lt;/p&gt;
&lt;p&gt;If you run into problems or need help, leave a comment below and I or the community can help troubleshoot.&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 straightforward when you follow the right steps. This guide shows you how to download the installation media and successfully install SQL Server 2022.&lt;/p&gt;
&lt;h2&gt;Downloading SQL Server 2022&lt;/h2&gt;
&lt;p&gt;To get the latest official version of SQL Server, go to &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;. This page has the most current SQL Server release available for download.&lt;/p&gt;
&lt;p&gt;If you need older or specific versions, head to the &lt;a href=&quot;https://www.microsoft.com/en-us/evalcenter&quot;&gt;Microsoft Evaluation Center&lt;/a&gt;. Find the SQL Server section and select the version you need.&lt;/p&gt;
&lt;p&gt;When downloading, you&apos;ll choose between Developer Edition and Express Edition. The &lt;code&gt;Developer Edition&lt;/code&gt; has all features and works well for testing, while &lt;strong&gt;Express Edition&lt;/strong&gt; is good for lightweight applications and development work, though it has some limitations.&lt;/p&gt;
&lt;p&gt;I&apos;m using Developer Edition for this guide.&lt;/p&gt;
&lt;h2&gt;Installing SQL Server 2022&lt;/h2&gt;
&lt;p&gt;After downloading, copy the &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 computer where you want to install SQL Server. Start the installation by right-clicking the file and selecting &quot;Run as Administrator.&quot;&lt;/p&gt;
&lt;p&gt;The installer gives you 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;: Provides more control over the installation process and allows for specific configurations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Download Media&lt;/strong&gt;: Allows you to download the full installation files for later use.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For most installations, &lt;strong&gt;Basic&lt;/strong&gt; works fine. Accept the License Terms, choose where to install, and click &lt;strong&gt;Install&lt;/strong&gt;. Installation usually takes a few minutes, but the time varies depending on your hardware and Windows version.&lt;/p&gt;
&lt;h2&gt;Post-Installation&lt;/h2&gt;
&lt;p&gt;When installation finishes, you&apos;ll see configuration details and confirmation that everything worked. If something goes wrong, SQL Server creates log files with more information to help troubleshoot the problem.&lt;/p&gt;
&lt;p&gt;If you need to cancel the installation, make sure to clean up any leftover files in the installation directory and uninstall any partially installed SQL Server components. Not doing this can cause problems with future installations.&lt;/p&gt;
&lt;p&gt;This guide only covers installation. It doesn&apos;t include security, best practices, or database configuration. You&apos;ll want to look at other resources to learn about those important topics. With installation complete, you&apos;re ready to start using SQL Server 2022 for creating databases or migrating existing ones.&lt;/p&gt;
&lt;p&gt;If you run into issues, search for the error code before asking for help. Most installation problems have known solutions that you can find online.&lt;/p&gt;
&lt;p&gt;You&apos;re all set!&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 managing SQL Server. SSMS is essential for Database Administrators who need to write T-SQL and analyze execution plans. Developers often use SSMS too, though there are Visual Studio Code plugins like &lt;a href=&quot;https://code.visualstudio.com/docs/languages/tsql&quot;&gt;this one&lt;/a&gt;. These don&apos;t provide the same experience as SSMS. I prefer SSMS over Visual Studio Code since I do more DBA work than development.&lt;/p&gt;
&lt;h2&gt;Downloading SSMS&lt;/h2&gt;
&lt;p&gt;You can always find the latest SSMS version on Microsoft&apos;s official website. To get the most current release, visit the &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-ver16#download-ssms&quot;&gt;SQL Server Management Studio download page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Just click the &lt;code&gt;Download SQL Server Management Studio (SSMS)&lt;/code&gt; link to start downloading the installer (&lt;code&gt;SSMS-Setup-ENU.exe&lt;/code&gt;). This file has everything needed for installation.&lt;/p&gt;
&lt;h2&gt;Installing SSMS&lt;/h2&gt;
&lt;p&gt;When the download finishes, go to where you saved the installer. Right-click the &lt;code&gt;SSMS-Setup-ENU.exe&lt;/code&gt; file and choose &lt;strong&gt;Run as Administrator&lt;/strong&gt; to start installing. If asked, click &lt;strong&gt;Yes&lt;/strong&gt; to let the program make changes to your system.&lt;/p&gt;
&lt;p&gt;The installation wizard opens with a few simple steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Choose Installation Location&lt;/strong&gt;: SSMS installs in Program Files by default, but you can change this if you want.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Click Install&lt;/strong&gt;: After picking your location, click &lt;strong&gt;Install&lt;/strong&gt; to begin.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Installation usually takes a few minutes to half an hour, depending on your computer. Be patient and let it finish.&lt;/p&gt;
&lt;h2&gt;Post-Installation&lt;/h2&gt;
&lt;p&gt;When installation is done, you&apos;ll see a message confirming SSMS installed successfully. You can now open SSMS from the Start Menu or by searching for it.&lt;/p&gt;
&lt;p&gt;You usually don&apos;t need to restart, but if you have problems or see a restart prompt, it&apos;s best to restart your computer.&lt;/p&gt;
&lt;h2&gt;Next Steps&lt;/h2&gt;
&lt;p&gt;With SSMS installed, you can now manage SQL Server instances, run queries, and configure settings. This guide only covers installation. For connecting to remote servers, database setup, or security practices, you&apos;ll need to look at other resources.&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;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;Recently I encountered a situation while migrating a database to a new SQL Server instance where the instance collation differed from the database collation, causing tempdb to become unusable. This discrepancy made tempdb unusable and caused JOIN, ORDER BY, GROUP BY, and other operations to fail. With a deadline to migrate these applications, I decided to rebuild the instance collation to match the new application databases.&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 proceeding, I backed up everything that would be lost due to the rebuild, including SQL logins, jobs, alerts, linked servers, and permissions. Ensuring recent backups of all databases is important for this process.&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 saved 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 removed 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 extracted, along with users and roles. I used the following script to create two stored procedures that help export the logins. I found these stored procedures on &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 official website&lt;/a&gt; which explains this process.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;EXEC sp_help_revlogin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I then executed the stored procedure to generate a script to create SQL Logins, which I saved to my REBUILD folder:&lt;/p&gt;
&lt;h3&gt;Script Msdb Objects&lt;/h3&gt;
&lt;p&gt;All steps in this section are optional, so only complete what you need. In my case, I needed to copy SQL Jobs, Alerts, and Operators. If you have Linked Servers, Endpoints, Extended Events, or db_mail, now would be the time to copy those configurations. I did this by expanding the SQL Server Agent in Object Explorer, selecting the Jobs folder, then pressing F7 to open Object Explorer Details. From there I highlighted the jobs I wanted and scripted them to a file in my REBUILD folder. I repeated these steps for Alerts and Operators.&lt;/p&gt;
&lt;h3&gt;Script Master Objects&lt;/h3&gt;
&lt;p&gt;Again, the following steps are optional and depend on how the SQL Server is used. I always install sp_WhoIsActive and similar stored procedures in the master database. Right-click on the objects you need to keep and script them, individually or together, to the REBUILD folder.&lt;/p&gt;
&lt;h2&gt;Rebuilding the System Databases&lt;/h2&gt;
&lt;p&gt;After completing those steps, I moved on to rebuilding the system databases. This step requires downtime. Log in to the database server and find your setup.exe file for SQL Server. I found mine in C:\Program Files\Microsoft SQL Server\160\Setup Bootstrap\SQL2022. I opened a CMD prompt and changed to that directory, then ran the following command:&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;Optionally, &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 specify in your 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 completed, 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>