Tuesday, November 10, 2026

T-SQL Tuesday #204: Are Blogs Still Relevant? Absolutely!

When I saw that this month's T-SQL Tuesday topic was 'Are Blogs Still Relevant?', I didn't have to think too long about my answer.

Absolutely.

Long before I began blogging, I relied heavily on other SQL Server professionals' blogs. They were a huge part of how I learned, researched problems, and approached my day-to-day work as a DBA. I can't count how many times another DBA's experience helped me solve a problem.

I've been writing about SQL Server in my own blog for years now. Sometimes a post comes from a customer problem, sometimes from a question I can't leave alone, and sometimes it's just something that catches my attention.

I want to write about things that matter. Problems that need solving, changes worth knowing about, or topics that challenge conventional thinking. Those are fun! Most importantly, I want to give my readers something they'll stop to read and want to try themselves -- and hopefully find useful.

Just recently, I was experimenting with SQL Server's missing-index recommendations. What started as a straightforward test led to more questions, more testing, and ultimately a post very different from the one I had planned to write.

Another example is my SQL Server replication troubleshooting post. It came from a real customer problem, and a reader later shared a similar experience with a different solution. That's another thing I love about blogging. Sometimes the learning continues long after I publish the post.

That's what I appreciate about technical blogging. It encourages us to investigate, not just read and repeat.

And Then There Are the Readers

Some of the most rewarding parts of blogging have been the comments, questions, corrections, and conversations that follow.

In a recent post about Error 7416 and linked servers after a SQL Server security update, a reader commented:

This is an excellent post and really helped me to understand what had happened when encountering this issue after upgrading to SQL Server 2025.

That meant something to me. Someone had encountered a real problem, found my post, and walked away with a better understanding of what had happened.

Another reader, commenting on my post about Microsoft's plans to eliminate NTLM, wrote:

Thank you so much for such a clear quick well organized post with helpful code and next steps.

That's exactly what I hope to accomplish when I write. Something useful, understandable, and practical.

But the conversations aren't always about solving a problem.

In my SSMS 22.7 Schema Compare experiment, a reader suggested several additional scenarios worth testing. They were good suggestions, and they gave me more to investigate.

That's another reason I value technical blogging. We don't just share what we know. We learn from one another. I really think that is the biggest point right there -- we learn from each other.

So, Are Blogs Still Relevant?

We certainly have more ways to find information today. AI can give us an answer in seconds. But a quick answer isn't always the best answer, and it certainly isn't a substitute for someone's real-world handson experience.

When someone takes the time to investigate a problem, test a solution, document the results, and share what they learned, that information has value.

And when someone else leaves a comment, asks a question, or contributes something new, that value grows.

So, are blogs still relevant? Absolutely!

I'll keep reading them, writing them, and testing what I share -- because the experience behind the words still matters.


This post is my contribution to T-SQL Tuesday #204, hosted by Jeff Iannucci.

Monday, November 9, 2026

SSMS 22.7.1 Schema Compare: Five More Reasons to Look Before You Apply

In my earlier post, SSMS 22.7 Schema Compare: Before You Click Apply, I explored some of the things that can happen when you trust Schema Compare to make changes without looking closely at what it's about to do.

Then Thomas Franz left a comment with five more scenarios he thought were worth testing. And they were good ones!

So, first, a big thank-you to Thomas for taking the time to share his ideas. This is one of my favorite things about technical blogging. Someone reads a post, brings another perspective, and suddenly there's more to investigate.

Back to the sandbox! This time I'm using SSMS 22.7.1, with separate source and target databases.

1. Renaming a Table

I started with two identical tables containing the same three rows. Then I renamed the source table from CustomerOrders to SalesOrders.

Schema Compare reported two differences: one Delete and one Add.

It didn't recognize the rename. The generated deployment script proposed dropping CustomerOrders and creating SalesOrders.

Fortunately, the generated script included a check intended to stop deployment if the original table contained data. But it was still proposing to drop and recreate a table that only needed a new name.

My takeaway: A simple rename isn't necessarily a simple deployment. Check the generated script before assuming it's just a rename.

2. System-Generated Constraint Names

Next, I created identical tables in both databases, allowing SQL Server to generate the primary key and default constraint names.

The primary key names were different, as expected. The default constraint names happened to be identical.

Schema Compare didn't report any additional differences for the table.

That was a pleasant result. At least in this test, different system-generated primary key names didn't produce unnecessary deployment changes.

3. Changing a Computed Column

For this test, both databases contained a table with a persisted computed column named Total. The source calculated Quantity * UnitPrice, while the target applied a 10% discount.

-- source
Total AS (Quantity * UnitPrice) PERSISTED
-- target
Total AS (Quantity * UnitPrice * 0.90) PERSISTED

Schema Compare correctly identified the difference as a Change.

Here's the relevant portion of its generated script:

ALTER TABLE [dbo].[ComputedColumnTest]
    DROP COLUMN [Total];

ALTER TABLE [dbo].[ComputedColumnTest]
    ADD [Total] AS ([Quantity] * [UnitPrice]) PERSISTED;

No table replacement was scripted. The computed column would be dropped and recreated, with its persisted values recalculated from the existing columns.

Of course, dependencies on that computed column could complicate things. My test table was deliberately simple.

My takeaway: Schema Compare handled this change reasonably, but DROP/ADD still deserves a look when dependencies are involved.

4. Same Columns, Different Order

This one got my attention. I created two tables with identical column names, data types, and nullability. The only difference was their order.

Schema Compare reported a Change, but in the generated script it proposed rebuilding the entire table. It would create a temporary table with the desired column order, copy the data, drop the original table, and rename the temporary table.

All that work just for different column order.

The good news is that Schema Compare has an Ignore Column Order option. I enabled it, ran the comparison again, and the difference disappeared.

My takeaway: Consider checking 'Ignore Column Order' in the Schema Compare options before allowing a cosmetic difference to trigger an unnecessary table rebuild.

5. Does Capitalization Matter?

For the final test, Thomas suggested comparing identifiers with different casing. I created two tables, one with a column named CustomerID and the other with customerid.

First, I used databases with the case-insensitive collation SQL_Latin1_General_CP1_CI_AS, and no differences were reported.

Then I repeated the test using two new databases with the case-sensitive collation SQL_Latin1_General_CP1_CS_AS. This time, Schema Compare reported a Change.

But here's the interesting part. Rather than generating a column rename for the difference in capitalization, it produced a DROP/ADD:

ALTER TABLE [dbo].[CaseTest]
    DROP COLUMN [customerid];

ALTER TABLE [dbo].[CaseTest]
    ADD [CustomerID] INT NOT NULL;

The generated script also warned about potential data loss and included this check to prevent deployment against a populated table.

IF EXISTS (SELECT TOP 1 1 FROM [dbo].[CaseTest])
    RAISERROR (N'Rows were detected. The schema update is terminating because data loss might occur.', 16, 127) WITH NOWAIT;

My takeaway: Collation matters, and even a case change can result in a potentially destructive deployment script.

Where I Landed

Here's how the five tests turned out:

Test What SSMS 22.7.1 Did
Table rename Proposed DROP/CREATE rather than a rename
Generated constraint names Ignored different system-generated primary key names
Computed column Proposed DROP/ADD of the computed column
Column order Proposed a table rebuild; Ignore Column Order eliminated it
Identifier casing Ignored under CI collation; proposed DROP/ADD under CS collation

Some results were reassuring. Others were exactly why I started looking more closely at Schema Compare in the first place.

These tests don't cover every possible dependency or deployment option, but they show why reviewing the proposed changes before running the scripts is important.

Very simply, Schema Compare can identify a difference correctly and still generate a deployment script you wouldn't want to run.

And once again, thanks to Thomas Franz for the suggestions. Five more tests, a few surprises, and another reminder that some of the best investigations begin in the comments.

Before you click Apply, be sure to look at what you're actually applying!

More to Read

sqlfingers — SSMS 22.7 Schema Compare — Before You Click Apply
Microsoft Learn: Schema comparison overview

Sunday, November 8, 2026

SQL Server's Missing Index Recommendations: What Aren't They Telling You?

SQL Server's missing-index recommendations can be pretty convincing. Especially when an execution plan tells you that adding an index could improve a query by more than 99%.

But how much can we really trust those recommendations? Are they telling us everything we need to know about a query's indexing needs, or are we only seeing part of the picture?

To find out, I put SQL Server 2025 through a simple test using the same query against several different index configurations. I wanted to see not just what SQL Server recommended, but also what was happening when it didn't recommend anything at all.

Starting With No Nonclustered Index

I created a test database with a 500,000-row SalesOrders table. Five columns and a clustered primary key on OrderID, but no nonclustered indexes.

CREATE TABLE dbo.SalesOrders (
    OrderID INT IDENTITY(1,1) NOT NULL
        CONSTRAINT PK_SalesOrders PRIMARY KEY CLUSTERED,
    CustomerID INT NOT NULL,
    OrderDate DATE NOT NULL,
    OrderStatus CHAR(1) NOT NULL,
    OrderTotal DECIMAL(12,2) NOT NULL
);
GO

;WITH Numbers AS
(
    SELECT TOP (500000)
        ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
    FROM sys.all_objects AS a
    CROSS JOIN sys.all_objects AS b
)
INSERT INTO dbo.SalesOrders
(
    CustomerID,
    OrderDate,
    OrderStatus,
    OrderTotal
)
SELECT
    (n % 10000) + 1,
    DATEADD(DAY, -(n % 1095), '20261101'),
    CASE WHEN n % 10 < 8 THEN 'C' ELSE 'O' END,
    (n % 5000) + 0.99
FROM Numbers;
GO

Then I ran this query with the actual execution plan enabled and STATISTICS IO turned on.

SET STATISTICS IO ON;

SELECT OrderID, OrderDate, OrderTotal
FROM dbo.SalesOrders
WHERE CustomerID = 43
  AND OrderStatus = 'C'
ORDER BY OrderDate DESC;

The query returned 50 rows, required 1,867 logical reads, and performed a Clustered Index Scan followed by a Sort.

SQL Server also offered a missing-index recommendation with an estimated impact of 99.3463%.

The recommendation was essentially this:

CREATE NONCLUSTERED INDEX idx_SalesOrders_Recommended
ON dbo.SalesOrders (CustomerID, OrderStatus)
INCLUDE (OrderDate, OrderTotal);

That's a reasonable recommendation. CustomerID and OrderStatus are used in the WHERE clause, and the included columns help cover the SELECT list.

But the estimated impact isn't a measurement of what actually happened. It's the optimizer's estimate of the potential reduction in query cost.

Following SQL Server's Advice

I created the recommended index and ran the same query again.

This time, SQL Server used a Nonclustered Index Seek. Logical reads dropped from 1,867 to just 3.

Table 'SalesOrders'. Scan count 1, logical reads 3,
physical reads 0, read-ahead reads 0.

And here's the execution plan:

That's a 99.8% reduction in logical reads. I'd call that a good recommendation -- but there was still a Sort operator because OrderDate was an included column rather than part of the index key.

This made me wonder whether moving OrderDate into the index key would eliminate the Sort. So I replaced the recommended index with this alternative and ran the SELECT again:

DROP INDEX idx_SalesOrders_Recommended
ON dbo.SalesOrders;
GO

CREATE NONCLUSTERED INDEX idx_SalesOrders_Alternative
ON dbo.SalesOrders
(
    CustomerID,
    OrderStatus,
    OrderDate DESC
)
INCLUDE (OrderTotal);

This time, the execution plan showed only an Index Seek. The Sort was gone.

The alternative maintained the same 3 logical reads while eliminating the Sort.

Table 'SalesOrders'. Scan count 1, logical reads 3,
physical reads 2, read-ahead reads 0.

For this small of a result set, it's difficult to establish a meaningful elapsed-time improvement from removing the Sort. But it does demonstrate that SQL Server's recommendation wasn't the only useful index definition.

Now for the Interesting Part

I removed the covering index and created a narrower one:

CREATE NONCLUSTERED INDEX idx_SalesOrders_Customer
ON dbo.SalesOrders (CustomerID)
INCLUDE (OrderDate, OrderTotal);

The index supports the CustomerID predicate and includes the columns we're returning, but OrderStatus is missing. That was intentional. I wanted to reproduce the kind of partially useful index I've encountered countless times in customer databases.

I ran the exact same query again.

This time, the execution plan showed a Nonclustered Index Seek, a Nested Loops operator, 50 Key Lookups and a Sort. The missing index recommendation? Gone.

I find it interesting that the index requires 50 key lookups and 165 logical reads -- but the server offers no missing-index recommendation.

 Table 'SalesOrders'. Scan count 1, logical reads 165, 
physical reads 0, page server reads 0, read-ahead reads 2

I checked the missing-index DMVs as well, but nothing was returned for the table.

We now had a query that could complete with 3 logical reads using a covering index, but was performing 165 logical reads with the narrower index. That's 55 times as many logical reads. Yet SQL Server wasn't recommending another index.

I questioned whether it may need more executions before generating another recommendation. So I ran the query 10 more times, each time using OPTION (RECOMPILE) to force a fresh optimization each time. But still, no missing-index recommendations.

Putting the Results Together

Index Configuration Logical Reads Sort Missing Index?
Clustered index only 1,867 Yes Yes
Recommended covering index 3 Yes No
Alternative covering index 3 No No
Narrower existing index 165 Yes No

One query. Four indexing configurations. Quite different results.

The narrower index was a substantial improvement over the original clustered scan, but it still required 55 times as many logical reads as the covering index. And SQL Server had nothing more to recommend.

What the Missing-Index Recommendation Didn't Tell Us?

Missing-index recommendations are based on optimizer estimates, not a complete evaluation of your indexing strategy. SQL Server found an index it could use, even though that index required 50 Key Lookups and 55 times as many logical reads as the covering index.

Even after 10 recompilations, SQL Server didn't generate another missing-index recommendation. The existing index was sufficient for the optimizer to choose an execution plan, but our measurements showed there was still considerable room for improvement.

Where I Landed

SQL Server's original recommendation was good. But this test showed me something equally important: an existing index can be good enough to satisfy the optimizer without being the best choice for the query.

I'll continue using missing-index recommendations as part of my investigations. But I'll also continue looking at execution plans, logical reads, and the indexes already in place before deciding what needs to change.

No missing-index recommendation doesn't mean no indexing opportunity.

More to Read

Microsoft Learn: Tune Nonclustered Indexes with Missing Index Suggestions
Microsoft Learn: sys.dm_db_missing_index_details

Friday, November 6, 2026

Your SQL Server Backup Succeeded. Can You Restore It?

Your SQL Server Backup Succeeded. But Can You Restore It?

Backup jobs can finish successfully every night, but by itself, does not tell you whether they can be restored.

A successful backup tells us that a bak file was written. It does not tell us whether that file will be accessible when we need it, whether the restore will complete successfully, or how long recovery will take. Those are things we can only establish by actually testing the restore.

I recently put together a monthly restore-validation process for a customer's Dynamics NAVISION database. It's SQL Server 2012, the database is over 2TB, and the storage is traditional hard disks rather than SSD, and the requirement was simple: restore the production backup file on a recurring basis to a separate server, and make sure the restored database can be read.

This particular restore completed in about 8 hrs and 35 minutes. A bit long, sure, but still a much better answer to 'Can we recover this database?' than just the backup-job history. Obviously, this duration would vary based on your conditions -- db size, disk, even the SQL Server edition.

The Plan

I kept it very simple and straightforward. A SQL Agent job runs monthly, with the restore in the first step and the data check in the second. A small log table records the status of each step.

StepWhat It Does
RestoreChecks bak file, confirms that it is new, restores it, and logs the outcome
ValidateChooses two random user tables and runs a TOP (1) SELECT against each
NotifySends email for restore or data-read failures, and for skipped/unsuccessful restore attempts

* There's also no need to take a new backup for this solution. It can use the .bak file already being produced by your targeted server's maintenance.

First, a Small Log Table

The log table records the file name, modification date, file size, stage, status, duration, and any error. That makes it possible to see what was tested, when it ran, and whether both steps completed.

CREATE TABLE [dbo].[BackupValidationLog](
	[LogID] [bigint] IDENTITY(1,1) NOT NULL,
	[JobStep] [varchar](30) NULL,
	[Details] [varchar](500) NULL,
	[BackupFile] [nvarchar](4000) NOT NULL,
	[FileModifiedDate] [datetime] NULL,
	[FileSizeBytes] [bigint] NULL,
	[DatabaseName] [sysname] NOT NULL,
	[ValidationStage] [varchar](20) NOT NULL,
	[RestoreStatus] [varchar](20) NOT NULL,
	[StartTime] [datetime] NOT NULL,
	[EndTime] [datetime] NULL,
	[DurationSeconds] [bigint] NULL,
	[ErrorNumber] [int] NULL,
	[ErrorMessage] [nvarchar](4000) NULL,
 CONSTRAINT [upkcl_BackupValidationLog_LogID] PRIMARY KEY CLUSTERED 
(
	[LogID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]

Restoring the Backup

The restore procedure starts by checking the bak file's last-modified date and size, and then compares that with the last restored file. If the file hasn't changed, there's no reason to restore it again. The state is recorded, notification is sent, process complete.

If it checks out as a new file, the procedure records a RUNNING status to the BackupValidationLog table before beginning the RESTORE DATABASE. Because this one always restores the same database, the command is hardcoded, but this could be easily changed to a lookup table if you were targeting multiple databases. When the RESTORE command returns, the log table is updated with SUCCESS or FAILED, along with the elapsed time and any error. An unsuccessful or skipped attempt generates an email.

I've included a @debug option that prints the RESTORE statement without restoring anything or writing to the log table. This is just helpful to verify file paths for the RESTORE.

CREATE PROCEDURE [dbo].[usp_RestoreBackup] (
 @debug BIT = 0
)
AS
/*
Used to perform recurring restores of NAVISION backups. Done solely to verify integrity of the bak file and recovery.
EXEC dbo.usp_RestoreBackup @debug = 1

Auth: Me
Date: 10/14/2026
*/
BEGIN
    SET NOCOUNT ON;

    DECLARE
        @Folder VARCHAR(4000) = '\\xxxxxxxxxxx\xxxxxxxxxxxx\xxxxxxxxxxx\',
        @FileName VARCHAR(260) = 'DBNAME_backup.bak',
        @DatabaseName SYSNAME = 'DBNAME',
        @MailProfile SYSNAME = 'MailProfile',
        @MailRecipients VARCHAR(MAX) = 'XXX@me.com',
        @BackupFile VARCHAR(4000),
        @FileDate DATETIME,
        @FileSize BIGINT,
        @StartTime DATETIME,
        @LogID BIGINT,
        @SQL NVARCHAR(MAX),
        @Command VARCHAR(8000),
        @CommandResult INT,
        @FileLine VARCHAR(255),
        @FilePosition INT,
        @RestoreStatus VARCHAR(20) = 'INCOMPLETE',
        @ErrorNumber INT,
        @ErrorMessage VARCHAR(4000),
        @Message VARCHAR(MAX);

    DECLARE @Directory TABLE (
        OutputLine VARCHAR(255));

    SET @BackupFile = @Folder + @FileName;

    -- RESTORE statement is modeled here so debug mode can display it before any processing or logging.
    SET @SQL = N'
RESTORE DATABASE DBNAME
FROM DISK = N''' + REPLACE(@BackupFile, '''', '''''') + N'''
WITH REPLACE, RECOVERY, STATS = 5,
MAXTRANSFERSIZE = 4194304,
BUFFERCOUNT = 100,
    MOVE N''DBNAME_Data'' TO N''F:\Program Files\Microsoft SQL Server\MSSQL11.MSSQLSERVER\MSSQL\DATA\NEWPROD.mdf'',
    MOVE N''DBNAME_1_Data'' TO N''F:\Program Files\Microsoft SQL Server\MSSQL11.MSSQLSERVER\MSSQL\DATA\NEWPROD_1.ndf'',
    MOVE N''DBNAME_2_Data'' TO N''F:\Program Files\Microsoft SQL Server\MSSQL11.MSSQLSERVER\MSSQL\DATA\NEWPROD_2.ndf'',
    MOVE N''DBNAME_3_Data'' TO N''F:\Program Files\Microsoft SQL Server\MSSQL11.MSSQLSERVER\MSSQL\DATA\NEWPROD_3.ndf'',
    MOVE N''DBNAME_4_Data'' TO N''F:\Program Files\Microsoft SQL Server\MSSQL11.MSSQLSERVER\MSSQL\DATA\NEWPROD_4.ndf'',
    MOVE N''DBNAME_5_Data'' TO N''F:\Program Files\Microsoft SQL Server\MSSQL11.MSSQLSERVER\MSSQL\DATA\NEWPROD_6.ndf'',
    MOVE N''DBNAME_datafile'' TO N''F:\Program Files\Microsoft SQL Server\MSSQL11.MSSQLSERVER\MSSQL\DATA\DBNAME_datafile.ndf'',
    MOVE N''DBNAME_Log'' TO N''F:\Program Files\Microsoft SQL Server\MSSQL11.MSSQLSERVER\MSSQL\Logs\NEWPROD_5.ldf'';';

    -- debug mode displays the generated RESTORE statement and exits.
    -- No file checks, restores, logging or notifications are performed.
    IF @debug = 1
    BEGIN
        PRINT @SQL;
        RETURN;
    END;

    BEGIN TRY
        SET @Command = 'DIR /A:-D /-C /T:W "' + @BackupFile + '"';

        INSERT @Directory (OutputLine)
        EXEC @CommandResult = master.dbo.xp_cmdshell @Command;

        IF @CommandResult <> 0
            RAISERROR('Backup file is inaccessible or missing.', 16, 1);

        SELECT TOP (1) @FileLine = OutputLine
        FROM @Directory
        WHERE RIGHT(RTRIM(OutputLine), LEN(@FileName)) = @FileName
          AND OutputLine LIKE '[0-9][0-9]/[0-9][0-9]/[0-9][0-9][0-9][0-9]%';

        IF @FileLine IS NULL
            RAISERROR('Unable to retrieve backup file metadata.', 16, 1);

        -- Assumes DIR returns MM/DD/YYYY HH:MM AM/PM.
        SET @FilePosition = LEN(RTRIM(@FileLine)) - LEN(@FileName) + 1;
        SET @FileDate = TRY_CONVERT(DATETIME,LTRIM(RTRIM(LEFT(@FileLine, 22))), 101);
        SET @FileSize = TRY_CONVERT(BIGINT,LTRIM(RTRIM(SUBSTRING(@FileLine, 23, @FilePosition - 23))));

        IF @FileDate IS NULL OR @FileSize IS NULL
            RAISERROR('Unable to parse backup file date or size.', 16, 1);

        -- Compare only against the last successful restore.
        -- An empty log table or a previously unsuccessful restore allows processing to continue.
        IF EXISTS (
            SELECT 1
            FROM (
                SELECT TOP (1) BackupFile, FileModifiedDate, FileSizeBytes
                FROM dbo.BackupValidationLog
                WHERE DatabaseName = @DatabaseName
                  AND ValidationStage = 'RESTORE'
                  AND RestoreStatus = 'SUCCESS'
                ORDER BY LogID DESC
            ) AS LastBackup
            WHERE BackupFile = @BackupFile
              AND FileModifiedDate = @FileDate
              AND FileSizeBytes = @FileSize
        )
        BEGIN
            SET @RestoreStatus = 'SKIPPED';
            SET @ErrorMessage = 'Backup file has not changed since the previous successful restore.';
        END
        ELSE
        BEGIN
            SET @StartTime = GETDATE();
            INSERT dbo.BackupValidationLog (
                JobStep, Details, BackupFile, FileModifiedDate, FileSizeBytes, 
                DatabaseName, ValidationStage, RestoreStatus, StartTime )
            VALUES (
                'Restore Backup', 'Performing the restore of ' + @FileName, 
                @BackupFile, @FileDate, @FileSize, @DatabaseName,
                'RESTORE', 'RUNNING', @StartTime );

            SET @LogID = SCOPE_IDENTITY();

            SET @RestoreStatus = 'RUNNING';

            EXEC sys.sp_executesql @SQL;

            SET @RestoreStatus = 'SUCCESS';
        END;
    END TRY
    BEGIN CATCH
        SET @ErrorNumber = ERROR_NUMBER();
        SET @ErrorMessage = ERROR_MESSAGE();

        IF @RestoreStatus = 'RUNNING'
        SET @RestoreStatus = 'FAILED';
    END CATCH;

    -- Record the error
    IF @LogID IS NULL
    BEGIN
        SET @StartTime = GETDATE();
        INSERT dbo.BackupValidationLog (
            JobStep, Details, BackupFile, FileModifiedDate, FileSizeBytes,
            DatabaseName, ValidationStage, RestoreStatus, StartTime, EndTime,
            DurationSeconds, ErrorNumber, ErrorMessage
        )
        VALUES (
            'Restore Backup', 'Performing the restore of ' + @FileName,
            @BackupFile, @FileDate, @FileSize, @DatabaseName,
            'RESTORE', @RestoreStatus, @StartTime, GETDATE(),
            0, @ErrorNumber, @ErrorMessage
        );
    END
    ELSE
    BEGIN
        UPDATE dbo.BackupValidationLog
        SET RestoreStatus = @RestoreStatus,
            EndTime = GETDATE(),
            DurationSeconds = DATEDIFF(SECOND, @StartTime, GETDATE()),
            ErrorNumber = @ErrorNumber,
            ErrorMessage = @ErrorMessage
        WHERE LogID = @LogID;
    END;

    -- send notification if restore failed
    IF @RestoreStatus <> 'SUCCESS'
    BEGIN
        SET @Message =
            'Backup validation: ' + @RestoreStatus + CHAR(13) + CHAR(10) +
            'Database: ' + @DatabaseName + CHAR(13) + CHAR(10) +
            'Backup file: ' + @BackupFile + CHAR(13) + CHAR(10) +
            'File modified: ' + COALESCE(CONVERT(VARCHAR(30), @FileDate, 121), 'Unknown') + CHAR(13) + CHAR(10) +
            'Reason: ' + COALESCE(@ErrorMessage, 'Unknown');

        EXEC msdb.dbo.sp_send_dbmail
            @profile_name = @MailProfile,
            @recipients = @MailRecipients,
            @subject = 'SQL Backup Validation - Restore',
            @body = @Message;
    END;

END;

Can We Read the Restored Database?

A completed RESTORE is the first milestone, but I also need to check whether the user tables can be accessed in the restored database.

This next procedure first looks at the latest RESTORE log entry and proceeds only when that entry says SUCCESS. It then selects two user tables at random using ORDER BY NEWID(), and calls a SELECT TOP (1) against each. The two table names and the result are written to the log table. If either query raises an error, the validation is marked FAILED and a notification is sent.

As I said, very simple and straightforward. But you don't need any more than that. Restore the bak file and verify that the database can be read. All done.

CREATE PROCEDURE [dbo].[usp_ValidateRestore]
AS
/*
Used to validate access to two randomly selected user tables following a successful restore.
EXEC dbo.usp_ValidateRestore

Auth: Me
Date: 10/14/2026
*/
BEGIN
    SET NOCOUNT ON;

    DECLARE
        @DatabaseName SYSNAME = 'DBNAME',
        @MailProfile SYSNAME = 'MailProfile',
        @MailRecipients VARCHAR(MAX) = 'XXX@me.com',
        @BackupFile VARCHAR(4000),
        @FileDate DATETIME,
        @FileSize BIGINT,
        @Table1 NVARCHAR(517),
        @Table2 NVARCHAR(517),
        @SQL NVARCHAR(MAX),
        @StartTime DATETIME,
        @LogID BIGINT,
        @Status VARCHAR(20) = 'SUCCESS',
        @ErrorNumber INT,
        @ErrorMessage VARCHAR(4000),
        @Details VARCHAR(500);

    -- Proceed only when the latest restore was successful.
    SELECT TOP (1)
        @Status = RestoreStatus,
        @BackupFile = BackupFile,
        @FileDate = FileModifiedDate,
        @FileSize = FileSizeBytes
    FROM dbo.BackupValidationLog
    WHERE DatabaseName = @DatabaseName
      AND ValidationStage = 'RESTORE'
    ORDER BY LogID DESC;

    IF @Status <> 'SUCCESS' OR @BackupFile IS NULL
    BEGIN
        INSERT dbo.BackupValidationLog (
            JobStep, Details, BackupFile, FileModifiedDate, FileSizeBytes,
            DatabaseName, ValidationStage, RestoreStatus, StartTime,
            EndTime, DurationSeconds
        )
        VALUES (
            'Validate Backup', 'Skipped because the latest restore was not successful.',
            @BackupFile, @FileDate, @FileSize,
            @DatabaseName, 'DATA', 'SKIPPED', GETDATE(),
            GETDATE(), 0
        );

        RETURN;
    END;

    -- EDIT: Simplified random table selection and validation.
    SET @StartTime = GETDATE();

    INSERT dbo.BackupValidationLog (
        JobStep, Details, BackupFile, FileModifiedDate, FileSizeBytes,
        DatabaseName, ValidationStage, RestoreStatus, StartTime
    )
    VALUES (
        'Validate Backup', 'Validating access to two user tables',
        @BackupFile, @FileDate, @FileSize,
        @DatabaseName, 'DATA', 'RUNNING', @StartTime
    );

    SET @LogID = SCOPE_IDENTITY();

    BEGIN TRY

        -- Select two random tables and read TOP (1) from each
        SET @SQL = N'
            USE ' + QUOTENAME(@DatabaseName) + N';

            SELECT TOP (2)
                QUOTENAME(s.name) + ''.'' + QUOTENAME(t.name)
            FROM sys.tables t INNER JOIN sys.schemas s
              ON t.schema_id = s.schema_id
            WHERE t.is_ms_shipped = 0
            ORDER BY NEWID();';

        DECLARE @Tables TABLE (TableName NVARCHAR(517));

        INSERT @Tables
        EXEC sys.sp_executesql @SQL;

        SELECT @Table1 = MIN(TableName),
               @Table2 = MAX(TableName)
        FROM @Tables;

        SET @SQL = N'
            DECLARE @Result INT;

            SELECT TOP (1) @Result = 1 FROM ' + QUOTENAME(@DatabaseName) + N'.' + @Table1 + N';
            SELECT TOP (1) @Result = 1 FROM ' + QUOTENAME(@DatabaseName) + N'.' + @Table2 + N';';

        EXEC sys.sp_executesql @SQL;

        SET @Details = 'Successfully read from ' + @Table1 + ' and ' + @Table2;

    END TRY
    BEGIN CATCH
        SET @Status = 'FAILED';
        SET @ErrorNumber = ERROR_NUMBER();
        SET @ErrorMessage = ERROR_MESSAGE();
        SET @Details = 'Validation failed: ' + @Table1 + ' and ' + @Table2;
    END CATCH;

    -- record failure
    UPDATE dbo.BackupValidationLog
    SET RestoreStatus = @Status,
        Details = @Details,
        EndTime = GETDATE(),
        DurationSeconds = DATEDIFF(SECOND, @StartTime, GETDATE()),
        ErrorNumber = @ErrorNumber,
        ErrorMessage = @ErrorMessage
    WHERE LogID = @LogID;

    -- send email notification of failure
    IF @Status = 'FAILED'
    BEGIN
        EXEC msdb.dbo.sp_send_dbmail
            @profile_name = @MailProfile,
            @recipients = @MailRecipients,
            @subject = 'SQL Backup Validation - Data Read Failed',
            @body = @ErrorMessage;
    END;

END;

What Did It Prove?

In short, the backup file is valid and the database is recoverable. This particular restore of a 2+ TB database completed in 8 hrs and 35 minutes on traditional hard disks. That also gives us a real measured restore duration, rather than an assumption based on database size.

Where I Landed

A successful backup is important. A successful restore tells us considerably more.

This solution gives us a recurring test of the actual backup file, confirmation that the restored database can be accessed, and a measured recovery duration. It doesn't replace a full disaster-recovery exercise, but it removes a significant amount of guesswork.

And for a database larger than 2TB, that's worth knowing before an emergency.

More to Read

Microsoft Learn: RESTORE Statements (Transact-SQL)
Microsoft: Backup overview (SQL Server)

Monday, November 2, 2026

Build the Fence Before Letting in the AI: The Complete sp_Blitz Series

I've spent the last couple of weeks experimenting with Claude Code and SQL Server, building a small application around Brent Ozar's sp_Blitz.

The goal was simple enough: collect SQL Server health check results, compare them over time, and identify what's new, what's fixed, and what hasn't changed.

I wasn't about to hand an AI agent the keys to my SQL Server and hope for the best. So I built a fence first and then I let the AI in, one step at a time.

The experiment turned into a three-part series:

Part 1: Build the Fence

Set up sp_Blitz, create a restricted SQL Server login, establish the AI agent's ground rules, and build the first comparison report. The agent writes the SQL. I run it.

Part 2: Let It Run

Give Claude Code a controlled connection, let it execute the report, and work through the SQL Server permissions needed to make it happen.

Part 3: Trust, but Verify

Clean up the schedule, manage the history, fix misleading comparisons, and use Extended Events to verify what the AI agent actually executed. Then see whether its explanation holds up to scrutiny.

Where I Landed

Three posts, one small application, and quite a few lessons along the way.

And I'm not finished. My next project will take this beyond reporting changes to fully automating the sp_Blitz health check, evaluating prioritized findings, and recommending corrective actions. Users will have the option to review those recommendations and, in some cases, even execute the corrections. But that introduces a whole new set of challenges, particularly around permissions, judgment, and knowing when not to make a change.

Looks like I have another fence to build.

Sunday, November 1, 2026

Build the Fence Before Letting in the AI: My sp_Blitz App, Part 3

In Part 1, I built the fence. In Part 2, I let Claude Code run the report without me, using a SQL login that could only read one table. The SQL ran, the output was saved, and the agent logged what it did. It also made a couple of guesses about its own execution that weren't supported by the evidence.

In this final part, I want to make my application more reliable, verify what Claude Code actually executes, and test whether the report can accurately distinguish meaningful changes from noise between sp_Blitz collections. Just as importantly, I want to examine whether the agent can correctly interpret those findings, or whether its conclusions still require a DBA's judgment.

Step 1: Give sp_Blitz a Sensible Schedule

Part 2 left me with a SQL Agent job, DBA - sp_Blitz to BlitzResults, running every 30 minutes. That's 48 collections a day --- far more than I needed for this experiment. One daily collection at 6:00 AM is enough. The Claude Code report can still compare the two most recent collections, but now those collections should normally be a day apart rather than 30 minutes.

I updated the existing SQL Agent schedule rather than creating another job:

It was only a schedule change. Nothing changed about the report's SQL Server login or its permissions. BlitzReader still only reads the results table; the SQL Agent job collects the findings.

Step 2: Keep the Last 30 Days

Before changing anything else, I checked the BlitzResults table for what I'd already collected. It held 5,365 rows across 147 collections and occupied about 2 MB. Small today, but a scheduled collection shouldn't grow forever just because nobody remembers to clean it up.

I chose a simple 30-day retention using CheckDate, which also identifies a collection in the report. This is my cleanup procedure:

CREATE OR ALTER PROCEDURE dbo.usp_CleanupBlitzResults
AS
BEGIN
    SET NOCOUNT ON;

    DELETE dbo.BlitzResults
    WHERE CheckDate < DATEADD(DAY, -30, SYSDATETIMEOFFSET());
END;
GO

I ran it manually and then added it in as a second step to the existing Agent job:

The sp_Blitz call proceeds to the cleanup on success, and the cleanup ends the job. The procedure executed successfully in my manual test, but we'd still want to verify that in job history after it is run under the BlitzRunner security context.

Step 3: When a Change Isn't a Change

With the collection schedule and cleanup taken care of, I turned my focus to the report itself. Collecting the findings is one thing, but the report also needs to tell me what's actually changed between this run and the last. Otherwise, I'll be spending time reviewing differences that don't really matter.

My application runs a report that compares the last two sp_Blitz collections and classifies findings as New, Fixed, Changed, or Same. That worked reasonably well, but I noticed something in the results. My app was reporting changes that weren't really changes.

One example was CheckID 94, 'Agent Jobs Without Failure Emails'. The report classified it as 'Changed' even though the job still had no failure notification configured. Looking closer at the 'Details' values, I found that it was just because the job's last-execution timestamp had changed. Since the report compared the entire 'Details' value, it treated that timestamp difference as a change to the finding itself.

I changed the comparison for CheckID 94 so it uses the job name instead of the entire 'Details' value. The job name is extracted from the text before ' has not been set up', leaving out the execution timestamp that was causing the false change. This gives the report a consistent value to compare between collections, so the same job can be classified as 'Same' even when its execution timestamp differs. The report still displays the original 'Details' values so I can see exactly what sp_Blitz recorded.

CASE
    WHEN b.CheckID = 94
     AND b.Details LIKE '% has not been set up%'
    THEN LEFT(b.Details,
              CHARINDEX(' has not been set up', b.Details) - 1)
    ELSE b.Details
END AS CompareDetails

That fixed the immediate false positive, but it raised another question. What happens when sp_Blitz returns multiple findings for the same check, and one of those findings disappears while another appears? Could my report distinguish the two, or would it incorrectly treat them as a single finding that changed?

Suppose CheckID 4, 'Sysadmins', reports Alice and Bob in one collection, and then Bob and Charlie in the next. Alice's finding has disappeared (Fixed), Charlie's has appeared (New), and Bob's remains unchanged (Same). Without matching by login name, the report could incorrectly treat Alice and Charlie as one finding that Changed.

To fix this, I updated the matching logic to identify each finding by its login name, just as I used the job name for CheckID 94. This prevents the report from treating two different logins or jobs as a single finding that Changed.

-- Identity used for CheckID 4
WHEN b.CheckID = 4
 AND b.Details LIKE 'Login [[]%] is a sysadmin%'
THEN LEFT(b.Details,
          CHARINDEX('] is a sysadmin', b.Details))

-- Additional condition in the leftover-row join
AND (
    COALESCE(c.CheckID, p.CheckID) NOT IN (4, 94)
    OR c.CompareDetails = p.CompareDetails
)

The actual comparison returned 39 findings:

StatusBeforeAfter
New22
Changed10
Fixed11
Same3536
Total3939

I also tested a replacement scenario with synthetic rows. Previous run: Alice and Bob. Current run: Bob and Charlie. The expected answer is Bob = Same, Alice = Fixed, Charlie = New. That's what the revised matching approach returned. No SQL Server logins were changed for the test.

Status   PreviousDetails   LatestDetails
Same     Login [Bob]       Login [Bob]
Fixed    Login [Alice]     NULL
New      NULL              Login [Charlie]

This isn't a universal solution for every sp_Blitz finding. It just addresses two checks whose identities I could establish from the actual data. Other checks with multiple rows under the same key may still need their own matching rules.

Step 4: What Did the Agent Actually Execute?

With the comparison logic corrected, I wanted to verify the next part of the process: what Claude Code actually executed. I don't mean from its own activity log. I wanted SQL Server's record of the Claude Code execution.

I created a temporary Extended Events session to capture completed SQL batches and RPC calls under BlitzReader. Then I'd have Claude run the report and compare its account with the events SQL Server recorded.

CREATE EVENT SESSION [BlitzReaderAudit]
ON SERVER
ADD EVENT sqlserver.sql_batch_completed
(
    ACTION
    (
        sqlserver.server_principal_name,
        sqlserver.sql_text,
        sqlserver.client_app_name
    )
    WHERE (sqlserver.server_principal_name = N'BlitzReader')
),
ADD EVENT sqlserver.rpc_completed
(
    ACTION
    (
        sqlserver.server_principal_name,
        sqlserver.sql_text,
        sqlserver.client_app_name
    )
    WHERE (sqlserver.server_principal_name = N'BlitzReader')
)
ADD TARGET package0.ring_buffer
(
    SET max_memory = 4096
)
WITH
(
    MAX_MEMORY = 4096 KB,
    EVENT_RETENTION_MODE = ALLOW_SINGLE_EVENT_LOSS,
    STARTUP_STATE = OFF
);
GO
ALTER EVENT SESSION [BlitzReaderAudit]
ON SERVER STATE = START;
GO

Now I returned to my existing C:\BlitzApp project in Claude Code and asked it first to list for me the project files and read CLAUDE.md, without changing or executing anything. Everything listed as expected. Then I asked it to run the existing report.sql, save the output in the reports folder, and record the attempt in attempts.md. Claude requested permission before executing the commands, and I gave my approval.

Claude reported that everything completed successfully. It provided the command it had executed, confirmed that the report was saved, and said the attempt was recorded in attempts.md:

Claude reported 39 findings, including no 'Changed' findings. But the command and the reported result were still Claude's account. The Extended Events session would tell me what SQL had actually been run.

For SQL Server's version of events, the XE session captured three completed SQL batches executed as BlitzReader, from SQLCMD, at 2:10 PM:

The first two batches were SQLCMD session settings. The third contained report.sql, including the corrected CheckID 4 and 94 comparisons. SQL Server recorded that batch executing under BlitzReader, from SQLCMD, at the time Claude reported.

That was the confirmation I wanted: the AI agent's claim about SQL execution matched SQL Server's evidence. Extended Events couldn't verify the report file or attempts.md, but it did establish what ran against the database.

Step 5: The SQL Was Right. Claude's Explanation Wasn't.

Running the right SQL doesn't mean interpreting the results correctly. Claude's summary made that distinction clear.

Claude warned about a five-day gap between collections and suggested the server or Agent job was probably down. There was no five-day gap. The collections were taken on October 31 at 12:30 PM and 1:00 PM, just 30 minutes apart. Claude had mistaken a job's last-execution timestamp in the Details column for a collection date. The report was correct; the explanation was not.

Claude did better with CheckID 160, 'Many Plans for One Query'. The finding disappeared after a server restart, and the report classified it as 'Fixed'. Claude correctly recognized that clearing the plan cache didn't necessarily fix the underlying parameterization problem.

That's the distinction: the report shows what changed between collections. Explaining why it changed takes more evidence. Even when the SQL is right, the agent's interpretation still needs checking.

Step 6: Put It to the Test

So far, I've corrected false changes and verified what the AI agent executed. But I still wanted to be sure that my application correctly reports a new sp_Blitz finding when a real SQL Server health problem is detected.

I chose PAGE_VERIFY, a setting used to detect corruption in database pages, where CHECKSUM is the recommended setting and NONE means that no checks are performed. I created a disposable database, BlitzPageVerifyTest, with PAGE_VERIFY CHECKSUM and established a baseline sp_Blitz collection.

Then I changed the setting to NONE and ran sp_Blitz again:

ALTER DATABASE BlitzPageVerifyTest
SET PAGE_VERIFY NONE;

As expected, sp_Blitz detected the problem as Priority 50, 'Page Verification Not Optimal'.

Now, to verify that my app reports this to me as a new finding, I asked Claude Code to run the existing report.sql without any modifications. My app compared the two most recent collections and returned 43 findings: 1 New, 0 Changed, 0 Fixed, and 42 Same. The new finding was the PAGE_VERIFY problem I'd deliberately introduced.

Then I enabled the correct page verification:

ALTER DATABASE BlitzPageVerifyTest
SET PAGE_VERIFY CHECKSUM;

And after another sp_Blitz collection, the Priority 50 finding disappeared. I had Claude Code run the report again, and this time it returned 1 Fixed, 0 New, 0 Changed, and 42 Same.

That's what I wanted to see. sp_Blitz detected the configuration problem, and my application correctly reported it as New when it appeared and Fixed when it disappeared. This was a very simple test, but it demonstrated exactly what I wanted: the application could distinguish a meaningful change in SQL Server's health from the noise between collections.

Limits

  • Daily collection isn't continuous monitoring. The report compares the latest two available collections. If one is missed, the interval may be longer than a day.
  • Matching is check-specific. I verified the identity rules for CheckIDs 4 and 94. I haven't proven that every other repeated CheckID can be safely paired by row order.
  • XE has a boundary. It confirmed the SQL submitted under BlitzReader, not the contents of files written on Windows or the accuracy of Claude's narrative.

Where I Landed

The job now has a daily schedule and a retention procedure. The report no longer mistakes a changing timestamp for a changed finding, and it can distinguish one sysadmin login disappearing from another appearing. Extended Events also gave me independent evidence of what Claude Code executed.

The final test brought everything together. I introduced a real database configuration problem, sp_Blitz detected it, and my application correctly reported it as New. When I corrected the setting, my app reported it as Fixed. Pretty simple, yes, but it did exactly what I wanted it to do. Very controlled. Very manageable.

Now I want to take this much further. The next project will automate the sp_Blitz health check, review the prioritized findings, and return recommended remediation for each, with the option to list the recommended corrective actions or execute them. That's a much bigger undertaking. Every recommendation will depend on the finding, the SQL Server environment, and the conditions under which a particular correction is appropriate. And before anything executes, I'll need to establish a new set of boundaries around what the AI agent is permitted to do.

And that's where the distinction between reporting a change and understanding it becomes even more important. Claude Code executed the right SQL and correctly reported the PAGE_VERIFY changes, yet earlier it had misinterpreted valid evidence and reached the wrong conclusion. The fence controls what the agent can do. It doesn't guarantee that what the agent tells me is correct. That responsibility still belongs to the DBA.

More to Read

sqlfingers: Build the Fence Before Letting in the AI, Part 1
sqlfingers: Build the Fence Before Letting in the AI, Part 2
Brent Ozar: SQL Server First Responder Kit
Microsoft: Extended Events

Sunday, October 25, 2026

Build the Fence Before Letting in the AI: My sp_Blitz App, Part 2

In Part 1, I built the fence. sp_Blitz writes to DBA.dbo.BlitzResults, BlitzReader can SELECT from that one table and nothing else, and Claude Code wrote a report that compares the last two runs. But I ran everything myself. Every sp_Blitz run, and every report.

Part 2 is going to take me out of the loop. sp_Blitz runs on a schedule, and Claude Code runs the report on a schedule, as BlitzReader, without me. And nothing runs as sysadmin that doesn't have to.

Component Details
SQL Server 2025 CU7, Enterprise Developer,
local dev instance
sqlcmd ODBC, 17.0.1000.7
AI agent Claude Code, desktop app
Project folder C:\BlitzApp

Step 1: Update the Ground Rules

Part 1's CLAUDE.md said not to connect to any database. That changes today. It can connect one way, as one login. It can also write scripts that change the server, but it never runs them. I do:

# Ground rules

- Work only in this project folder.
- Connect to SQL Server only with sqlcmd, as BlitzReader:
  sqlcmd -S sqlfingersinc -U BlitzReader -d DBA
  The password comes from the SQLCMDPASSWORD environment variable. Never put it on the command line.
- All SQL you run is SELECT-only, against DBA.dbo.BlitzResults.
- Every query has a TOP clause.
- You may write scripts that change the server. You never run them. I do.
- Ask before running any command.
- Log every attempt in attempts.md: what you tried, what worked, what didn't.

Don't forget what we learned in Part 1. CLAUDE.md is guidance. SQL Server is the fence.

Step 2: Give It a Connection

The password doesn't go on the command line. Microsoft says -P is insecure and recommends the SQLCMDPASSWORD environment variable instead (sqlcmd utility).

Claude Code can set environment variables for its sessions with an env block in its settings. I put it in .claude/settings.local.json, which applies only to me, in this project (Claude Code settings):

{
  "env": {
    "SQLCMDPASSWORD": "[USE A STRONG PASSWORD]"
  }
}

To be clear, the agent can read that file. The password is still stored in plain text, just not exposed on the command line. That's acceptable for this local experiment, but it's not how I'd manage credentials in production. SQL Server restricts BlitzReader to SELECT on one table. That's the fence.

First connection test, in a new session with the folder set to BlitzApp and the mode set to Manual:

Read CLAUDE.md. Connect to SQL Server with sqlcmd as BlitzReader and run this query, then show me the result:

SELECT TOP (5) CheckDate, COUNT(*) AS Findings
FROM DBA.dbo.BlitzResults
GROUP BY CheckDate
ORDER BY CheckDate DESC;

It didn't connect:

ODBC Driver 18 encrypts by default and validates the server's certificate, and a fresh local install uses a self-signed one. The agent explained that, logged the attempt, offered two fixes, and asked before trying either. I chose -C, which tells sqlcmd to trust the server certificate without validating it (sqlcmd utility). The connection is still encrypted. For a local dev instance, that's a reasonable trade.

Look at what's not on that command line: a password. sqlcmd picked it up from SQLCMDPASSWORD, exactly as planned.

It worked. The agent logged in as BlitzReader, read the table, and returned the results. It asked for permission before writing what it had done to attempts.md. I also added the -C into the sqlcmd line in CLAUDE.md, so the agent will connect the same way from here on.

Step 3: A Least-Privilege Job Owner

A scheduled job needs an owner, and that owner shouldn't be me, because I'm sysadmin. Any T-SQL job step owned by a non-sysadmin runs as that owner (sp_add_jobstep), so the owner needs exactly what sp_Blitz needs -- and nothing more.

Brent's original documentation says sp_Blitz needs sysadmin, but I decided to grant BlitzRunner only the minimum perms needed to get the job done. I gave EXECUTE on sp_Blitz and INSERT on BlitzResults. I figured I'd run sp_Blitz twice, first as myself and then as BlitzRunner, and compare the output. But...

A hard stop. sp_Blitz wouldn't run at all, and nothing was written to the table. So I granted VIEW SERVER STATE and tried again. This time it ran, but only partially:

So, I can see right out the gate that my initial permissions weren't enough. From there, sp_Blitz's own output told me what to grant next. I enabled one permission at a time, testing after each change. Eventually, BlitzRunner returned the same findings as my sysadmin run, with one exception I'll get to below. Three things I learned along the way:

  • VIEW SERVER STATE is the only hard requirement. Without it, sp_Blitz stops before writing anything. With it, a non-sysadmin run works to some extent, and sp_Blitz adds a 'Some Checks Skipped' row to say it couldn't run everything.
  • BlitzRunner needs SELECT on the database where sp_Blitz is installed -- DBA, in my case. sp_Blitz builds its list of databases it may check using fn_my_permissions, passing each database's name. Any database missing from that list is skipped. Microsoft's documentation says the securable should be NULL for a database (fn_my_permissions). In my tests, the answer depended on the database sp_Blitz ran from. SELECT in msdb alone changed nothing. SELECT in DBA, where sp_Blitz is installed, brought every database back to the list, and the backup, Page Verification and Agent findings came back as well.
  • The version check calls the rest of the kit. sp_Blitz checks the versions of the other First Responder Kit procedures by executing them. Without EXECUTE on each one, it raises a permission error and records a 'Version Check Failed' finding.

The final sp_Blitz permission set, one script:

USE master;
GO
CREATE LOGIN BlitzRunner
    WITH PASSWORD = '[USE A STRONG PASSWORD]',
    CHECK_POLICY = ON;

CREATE USER BlitzRunner FOR LOGIN BlitzRunner;

GRANT VIEW SERVER STATE TO BlitzRunner;
GRANT VIEW ANY DEFINITION TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_MSgetalertinfo TO BlitzRunner;
GO

USE msdb;
GO
CREATE USER BlitzRunner FOR LOGIN BlitzRunner;
GRANT SELECT TO BlitzRunner;
GO

USE DBA;
GO
CREATE USER BlitzRunner FOR LOGIN BlitzRunner;
GRANT SELECT TO BlitzRunner;
GRANT INSERT ON dbo.BlitzResults TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_Blitz           TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzAnalysis   TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzBackups    TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzCache      TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzFirst      TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzIndex      TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzLock       TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzWho        TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_DatabaseRestore TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_ineachdb        TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_kill            TO BlitzRunner;
GO

Everything there is read access or permission to call a procedure. No writes outside of BlitzResults, but EXECUTE deserves a closer look. Depending on how a procedure is written and its execution context, it may perform operations the caller couldn't execute directly. I granted these permissions to get the First Responder Kit working under BlitzRunner, but that doesn't make every procedure harmless.

The proof. My sysadmin run against BlitzRunner's final run, compared on CheckID, DatabaseName and Finding:

Seen	       CheckID	DatabaseName	Finding
Baseline only	   156	NULL	Oct 22 2026  6:06PM
BlitzRunner only   156	NULL	Oct 22 2026  8:03PM
BlitzRunner only   223	NULL	Some Checks Skipped

CheckID 156 is the run date, so it never matches between runs. The other findings matched, but CheckID 223, 'Some Checks Skipped,' remained. That's important. The matching findings are encouraging, but I can't claim identical coverage until I know exactly what was skipped.

Step 4: Schedule sp_Blitz

In Part 1, I ran sp_Blitz by hand. This time I want it running on a schedule, and the job will be owned by BlitzRunner, the login we configured above in Step 3. It has substantially fewer privileges than sysadmin, but I will say that I haven't established that every permission granted is strictly necessary.

My CLAUDE.md lets the agent write the scripts, but not run them. And even if it tried, it would fail, because its only connection is BlitzReader -- who has only SELECT on BlitzResults. This is my request to Claude Code:

What it wrote:

EXEC msdb.dbo.sp_add_job
    @job_name = N'DBA - sp_Blitz to BlitzResults',
    @owner_login_name = N'BlitzRunner';

EXEC msdb.dbo.sp_add_jobstep
    @job_name = N'DBA - sp_Blitz to BlitzResults',
    @step_name = N'Run sp_Blitz',
    @command = N'EXEC DBA.dbo.sp_Blitz
    @CheckUserDatabaseObjects = 0,
    @OutputDatabaseName = ''DBA'',
    @OutputSchemaName = ''dbo'',
    @OutputTableName = ''BlitzResults'';';

-- schedule: every 30 minutes

That's trimmed to the parts that matter. The full script also stops without making changes if BlitzRunner doesn't exist or the job is already there, and it does everything in one transaction so a failure leaves nothing behind. I didn't change any thing. I ran it in SSMS, started the job once by hand, and checked the job history:

'Executed as user: BlitzRunner.' The job runs under the dedicated login, not my sysadmin account.

Step 5: Schedule the Report

Half of the job is done. sp_Blitz runs every 30 minutes and writes its findings to BlitzResults, and I'm not involved at all. The last half is the report, but right now I still have to run report.sql myself.

I want Claude Code to do that as well. Once a day, it should connect as BlitzReader, run the report, save the output, and log what it did, all without me sitting there watching it or having to give permission. Claude Code's desktop app can do this with a scheduled task (Claude Code: Desktop scheduled tasks). It runs right here on my machine, where everything the agent needs already lives: CLAUDE.md, report.sql, and sqlcmd.

This is what I asked for, in the same BlitzApp session:

Create a scheduled task for this project that runs every hour. Each run should read CLAUDE.md, run report.sql with sqlcmd as BlitzReader using the connect line in CLAUDE.md, save the output to a new file in a reports folder named report_YYYYMMDD_HHMM.txt, and log the run in attempts.md. It should not run anything else.

I initially asked for hourly runs, but that didn't make much sense. report.sql compares the last two sp_Blitz runs, and those are only 30 minutes apart. So I changed the schedule to once a day at 5:00PM. Before scheduling anything, Claude read report.sql and found it had changed since it wrote it:

The scheduled task already connects as BlitzReader, so those lines weren't needed. I let it remove them, and report.sql is SELECT-only again. Then it showed me the task it planned to create:

Read the second paragraph of the task instructions. It says I approved those commands for unattended runs, but I didn't. The agent wrote that about me into its own instructions, and every run would have started out believing it. I had that paragraph removed.

With that paragraph gone, the task follows CLAUDE.md and asks before running any command. The first scheduled run did exactly that. It started at 5:07 PM, stopped 13 seconds later, and waited nearly four hours for me. As-is, every command waits for permission to run, and that must be done every time.

Not quite the hands-off I was going for, so I added allow rules to my user settings file, C:\Users\rebec\.claude\settings.json. Claude Code applies those to scheduled task runs (Claude Code: Desktop scheduled tasks), so a command that matches a rule runs without asking. I wrote one for each thing the task does: get the timestamp, run the report, and log the run. The sqlcmd rule is listed twice, once for PowerShell and once for Bash, so it's covered either way:

{
  "permissions": {
    "allow": [
      "PowerShell(Get-Date -Format yyyyMMdd_HHmm)",
      "PowerShell(sqlcmd -S sqlfingersinc -U BlitzReader -d DBA -C -W -i report.sql -o reports\\report_*)",
      "Bash(sqlcmd -S sqlfingersinc -U BlitzReader -d DBA -C -W -i report.sql -o reports\\report_*)",
      "Edit(//c/BlitzApp/attempts.md)"
    ]
  }
}

Each rule targets a specific command or file. The * allows the report's file name to change with each run, but it's a wildcard, not a timestamp validator. These rules control when Claude asks for approval; they don't enforce what SQL Server will allow. I also had Claude rewrite the task to run each command on its own, because a chained command runs without asking only if every part of it matches a rule (Claude Code permissions). This is the one place I loosened the agent's permissions, but the fence didn't move. The command still connects as BlitzReader.

Step 6: Hands Off

With the rules in place, I clicked Run now on the task and kept my hands off. Nothing asked for permission, and nothing waited on me. It checked report.sql, got the timestamp, ran the report as BlitzReader, saved the output, logged what it did, and completed the task with this output:

Look at the first item under 'Things to look at' in that screenshot. The run didn't start late. I started it at 19:29 with Run now. The agent didn't know that, so it guessed, and reported the guess as something for me to look at.

The report ran. The output was saved. The log was written. All of that worked, but the agent also gave me an explanation for something it didn't know, and presented it as a finding. I think it's important to see that successful execution doesn't necessarily mean accurate reporting.

Limits

  • One instance. Everything here is from my local dev instance. BlitzRunner returned the same findings as my sysadmin login, but it also reported that some checks were skipped. I haven't established identical coverage, and the permissions required may differ on another server.
  • The app has to be open. Claude Code's scheduled tasks run only while the desktop app is running and the computer is awake (Claude Code: Desktop scheduled tasks). The sp_Blitz job doesn't care. The report does.
  • An allow rule matches text, not intent. Claude Code says its command rules aren't a security boundary around a program (Claude Code permissions). Mine are narrow, but the real fence is still BlitzReader.
  • The table grows. At 37 rows a run, every 30 minutes, BlitzResults gains 1,776 rows a day. There's no cleanup yet.
  • The agent's log isn't proof. attempts.md is what the agent says it did, and twice now it has guessed wrong about its own runtimes.

What's Next

In Part 3, I'll turn this into something I'd actually run. sp_Blitz moves to a realistic schedule, BlitzResults gets a cleanup, and the report stops calling noise a change. And since the agent has now guessed wrong about its own runs, I'll check what it says it did against SQL Server's own record of what BlitzReader actually ran.

More to Read

sqlfingers: Build the Fence Before Letting in the AI: My sp_Blitz App, Part 1
Brent Ozar: SQL Server First Responder Kit
Brent Ozar: Using Claude Code with SQL Server and Azure SQL DB