Wednesday, October 21, 2026

Automatic Index Compaction: What Happens When I Rebuild Anyway?

Bob Ward recently posed an interesting question on Data Exposed: Is automatic index compaction the end of index maintenance?

That's a pretty big question for those of us who've spent yeeeears managing indexes, and Bob didn't just talk about it. He published the scripts to demonstrate what it can do, so I had to try it myself. 😀

Bob's demo was on Azure SQL Hyperscale with 8 vCores. I used something a bit more modest with a free Azure SQL Database with 2 vCores. Otherwise, I followed his experiment and watched automatic compaction recover the space left behind after deleting nearly half a million rows.

It worked. Very well. But the scan after compaction was slower than I expected, and that's where this got interesting.

Side note; I actually covered this feature back in April in Your Nightly Index Rebuild Job May Be On Its Way Out -- but I only talked about it. This time I get to show what it actually does.

Bob's Demo, My Smaller Database

My test used the Azure SQL Database free offer: General Purpose, serverless, Standard-series (Gen5), 2 vCores and 32 GB of storage. The offer includes a monthly allowance of 100,000 vCore seconds and 32 GB of data per database, subject to terms and limits. This is not Bob's 8-vCore Hyperscale environment, and that distinction matters when we get to scan times.

The demo scripts are in microsoft/bobsql, demos/auto_index_compaction. The test creates a million-row table with a GUID clustered key and fixed-width char(100) data, rebuilds the index to establish a dense starting point -- and then deletes roughly half the rows across the table.

For the scan measurements, I used Bob's single-threaded, cold-cache COUNT_BIG test:

CHECKPOINT;
DBCC DROPCLEANBUFFERS;
GO

SET STATISTICS TIME ON;
SET STATISTICS IO ON;

SELECT COUNT_BIG(*) AS row_count
FROM dbo.aic_demo
OPTION (MAXDOP 1);

SET STATISTICS TIME OFF;
SET STATISTICS IO OFF;
GO

The workload was intentionally simple: one table, one full scan, and no concurrent application workload. I followed Bob's demonstration with one additional UPDATE to address a compaction timing issue that I'll explain shortly.

Putting the Index to the Test

After setup, the million-row table required 17,005 logical reads for the scan, which completed in 867 ms. With that baseline established, I ran Bob's degradation script. It deletes rows using CHECKSUM(id) % 2 = 0, spreading the deletions throughout the clustered index rather than removing one continuous range.

MeasurementBaselineAfter Delete
Row count1,000,000500,252
Logical reads17,00534,058
Elapsed time867 ms2,143 ms

Half the rows were gone, yet the scan required twice as many logical reads and took more than twice as long. The degradation script also reported 33,899 pages at just 52.46% density. Bob's README explains the role of Accelerated Database Recovery (ADR) in that the versioning overhead from the deletes can cause previously full pages to split. In my run, that effect was substantial.

Fewer rows, more work, and a slower scan. That's exactly the kind of problem automatic index compaction is intended to address.

Compaction Was On. Nothing Happened.

I enabled automatic index compaction after the delete, then checked its progress. The database reported the feature as ON. The page count didn't move. Ten minutes later, it still hadn't moved.

Automatic index compaction enabled with no change in page count

This wasn't evidence that compaction had failed. It was a timing problem. Automatic compaction works in conjunction with version cleanup, and MSFT's FAQ explains why enabling it after the relevant modifications may leave existing low-density pages untouched. Bob's README also calls attention to the timing.

For a planned large delete, that's worth knowing before you begin. Enabling compaction afterward does not guarantee that it will revisit the pages you've already changed.

But I still wanted to see what compaction could accomplish with those half-empty pages. Since enabling it after the delete hadn't produced any change, I needed to generate new activity while the feature was enabled.

So I updated every remaining row. This was my addition, not part of Bob's original test:

UPDATE dbo.aic_demo
SET val = val + 1;

That affected 500,252 rows and generated new modifications for cleanup to process. My final compacted state was reached through a slightly different path than Bob's original demonstration.

Now Compaction Had Something to Do

A few minutes later, after things settled, two consecutive progress checks using Bob's 05-check-progress.sql query returned the same result:

Rows:                 500,252
Page count:             8,563
Page density:          98.86%
Fragmentation:         96.55%

From 33,899 pages down to 8,563, without rebuilding the index. That's roughly a 75% reduction in allocated index pages from the degraded state. Automatic compaction had done exactly what it was designed to do -- restoring page density and substantially reducing the number of pages.

But -- the fragmentation was still sitting at 96.55%. That's an important distinction because automatic compaction is designed to improve page density, not eliminate fragmentation. Improving page density isn't necessarily going to restore the logical ordering of the index pages. Microsoft says that here, and my results illustrated it very clearly.

So I had an index that was nearly 99% dense, with about a quarter of its previous page count, but still heavily fragmented. What's that going to mean for query performance?

The Scan Was the Surprise

With so many fewer pages to read, I expected the scan to improve, and in one respect, it did. Logical reads dropped from 34,058 in the degraded state to only 8,722 post compaction. But even with that reduction, the scan still took considerably longer to complete.

The first scan during the final stages of compaction took 6,931 ms, and another after the page count settled took 7,045 ms. Both substantially slower than the 2,143 ms scan before compaction, regardless of the large improvement in page density. So, with fragmentation still at 96.55%, I was curious what a rebuild might change.

What If I Rebuild It Anyway?

I ran one final statement:

ALTER INDEX pk_aic_demo
ON dbo.aic_demo REBUILD;

When complete, the index had 8,479 pages at 99.84% density, compared with 8,563 pages at 98.86% density after compaction. That's minimal difference, but the fragmentation dropped from 96.55% to 0.00%.

Here I ran the same cold-cache scan again and it completed in 439 ms. A second run took 440 ms. Both reported 8,508 logical reads, with read-ahead reads of 8,503.

That's a very different result from the compacted scans. And unlike the original demonstration, the question wasn't whether compaction could reclaim space. Obviously, it could. The question now was whether the rebuild still provided any gains after the compaction had been done, and the density restored.

All Four States, Side by Side

StateLogical readsRead-ahead readsElapsed (ms)
Baseline17,00516,998867
Degraded34,05834,0492,143
Compacted8,72232,8367,045
Rebuilt8,5088,503440

The baseline and degraded states were each measured once. For the compacted and rebuilt states, I ran the scan twice and used the second result in the comparison above.

The rebuilt scan finished about 16 times faster than the compacted scan in this test, even though the two indexes had almost the same page count and density.

Fragmentation may have contributed to that difference. Microsoft documents how gaps left by compaction can reduce the size of read-ahead I/O. But this test wasn't designed to isolate fragmentation from other factors affecting execution time, so I can't really attribute the entire improvement to the rebuild correcting page order.

So Is it the End of Index Maintenance?

Not on the strength of this test. Automatic compaction was very effective at restoring page density. It took an index that was only 52.46% dense and brought it to 98.86%, reducing its page count remarkably without a rebuild. That's a very meaningful benefit, especially for workloads that create low-density pages through recurring deletes and updates.

The rebuild produced a much faster full scan under my test conditions, but that doesn't mean every compacted index needs rebuilding. And it certainly doesn't mean this 2-vCore General Purpose result predicts what Bob would see on Hyperscale. What I believe it does tell us is that reclaiming space and improving scan performance are not always the same thing.

There are two operational details I'd keep in mind. First, automatic compaction isn't a retroactive fix for every low-density page (the timing of modifications matters). Second, automatic compaction does not update statistics, so it doesn't replace statistics maintenance.

For me, the takeaway isn't to disable compaction or keep rebuilding everything on a schedule. It's to let compaction handle what it handles well, and then to use actual workload measurements to decide whether any index maintenance is still needed.

What This Test Doesn't Prove

This was one table and one single-threaded, cold-cache full scan on a 2-vCore General Purpose serverless database. Baseline and degraded scans were each measured once and the compacted and rebuilt scans were measured twice. I did not test seeks, range queries, warm-cache performance, concurrency, or any other service tiers. I also added a full-table UPDATE after missing the initial compaction window, which changes the sequence of work relative to Bob's demo.

Those limits matter. I wouldn't use these numbers to predict a production performance gain, but I would use them as a reason to measure before removing an established maintenance operation.

Cleanup

Bob's 06-cleanup.sql script removes the demo table and turns the feature off:

DROP TABLE IF EXISTS dbo.aic_demo;
GO

ALTER DATABASE CURRENT
SET AUTOMATIC_INDEX_COMPACTION = OFF;
GO

If you're testing on the free offer, disconnect SSMS when you're finished, including Object Explorer. Open sessions can prevent serverless auto-pause and consume your included vCore seconds.

More to Read

Bob Ward: The End of Index Maintenance? — Data Exposed
Bob Ward's demo scripts — microsoft/bobsql
Microsoft Learn: Automatic index compaction
Microsoft Tech Community: Stop defragmenting and start living
Azure SQL Database free offer
SQLFingers: Your Nightly Index Rebuild Job May Be On Its Way Out

Monday, October 19, 2026

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

Last month, Grant Fritchey's AI coding agent went looking through his laptop for a running PostgreSQL instance. It found one that was shut down, and started it up on its own, without being asked. The agent was trying to validate its changes before deploying them. Good instinct. Unauthorized action. You can read the whole story in part six of his Simple Talk series.

Grant's takeaway? Guardrails in the prompt weren't enough. You need a real barrier between the AI and anything that matters.

I have never used an AI coding agent. Yes, I know. WTH am I waiting for...

This is it. I'm building an app around Brent Ozar's sp_Blitz, and I'm documenting the process in a series. But before I let an AI agent anywhere near my SQL Server, I want to establish what it can and cannot do. First the fence. Then the AI.

The App

Brent's sp_Blitz is the health check many of us already use. Run it once, and it tells you what's wrong today. Save the output run after run, and you have a history of every finding on the server(s) you run it against.

My app turns that history into a comparison -- what's new since last run, what's been fixed, and what keeps coming back. The data collection is already built into sp_Blitz. Pass it @OutputDatabaseName, @OutputSchemaName and @OutputTableName, and it creates the table, if needed, and writes the results to it. That table is my app's data source.

Credit where it's due: when Brent added table output in sp_Blitz v22, he included the server name and check date so we could compare runs (sp_Blitz v22). And in March, he wrote about using Claude Code with SQL Server, including the importance of thinking about where you point it and what login you give it (Using Claude Code with SQL Server).

The comparison builds on Brent's work. The fence, and what the agent does inside it, are mine.

Each post in this series adds something new. In Part 1, I keep it simple: run sp_Blitz multiple times, save the findings, and report what's new, what's changed, and what's stayed the same.

The setup:

Piece Choice
SQL Server 2025 CU7, local dev instance
Health check sp_Blitz, First Responder Kit
AI agent Claude Code, desktop app

Why Not Just Point It at SQL Server?

Because it would connect as me. If the agent uses Windows authentication, it gets my login, and my login is sysadmin.

Claude Code has its own permission rules, and it reads a rules file called CLAUDE.md at the start of every session. But per Anthropic's own documentation, CLAUDE.md only shapes what Claude tries to do. It doesn't change what Claude Code allows (Claude Code permissions). Guidance isn't a barrier.

So I have three layers, and only one of them is mine to enforce:

Layer Enforced by
CLAUDE.md The agent interprets it; SQL Server doesn't enforce it.
Claude Code
permissions
Claude Code.
SQL Server
permissions
SQL Server. The fence.

I'll use all three -- but trust the last one the most.

Step 1: Install the First Responder Kit

Brent recommends master, but any database is supported, so I put it in my DBA database. Create that first:

CREATE DATABASE DBA;
GO

Then grab the latest release and run Install-All-Scripts.sql in DBA. It installs sp_ineachdb with sp_Blitz, which sp_Blitz needs in the same database (README).

Step 2: Run sp_Blitz Into a Table

Run sp_Blitz with the output parameters. @CheckUserDatabaseObjects = 0 skips the checks inside user databases, which is the fastest way to run it:

EXEC DBA.dbo.sp_Blitz
    @CheckUserDatabaseObjects = 0,
    @OutputDatabaseName = 'DBA',
    @OutputSchemaName = 'dbo',
    @OutputTableName = 'BlitzResults';

Confirm it landed:

SELECT COUNT(*) AS Findings
FROM DBA.dbo.BlitzResults;

Step 3: Build the Fence

The agent gets its own login. It can read one table. That's it.

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

USE DBA;
GO
CREATE USER BlitzReader FOR LOGIN BlitzReader;
GRANT SELECT ON dbo.BlitzResults TO BlitzReader;
GO

That's the fence. BlitzReader can read one table, BlitzResults, and it was granted nothing else. It can't run sp_Blitz, and it can't touch anything else in DBA. When the agent connects, it connects as BlitzReader. Whatever it attempts to do, SQL Server decides what to permit.

One note: BlitzReader is a SQL login, so the instance needs to be in SQL Server and Windows Authentication mode before anything can connect with it.

Step 4: Give It Something to Find

To test the comparison, I needed something to change. I made a throwaway database and gave sp_Blitz two things to flag, one at a time: Auto-Shrink and Page Verification.

CREATE DATABASE BlitzDemo;
GO
ALTER DATABASE BlitzDemo SET AUTO_SHRINK ON;
GO

EXEC DBA.dbo.sp_Blitz
    @CheckUserDatabaseObjects = 0,
    @OutputDatabaseName = 'DBA',
    @OutputSchemaName = 'dbo',
    @OutputTableName = 'BlitzResults';
GO

ALTER DATABASE BlitzDemo SET AUTO_SHRINK OFF;
ALTER DATABASE BlitzDemo SET PAGE_VERIFY NONE;
GO

EXEC DBA.dbo.sp_Blitz
    @CheckUserDatabaseObjects = 0,
    @OutputDatabaseName = 'DBA',
    @OutputSchemaName = 'dbo',
    @OutputTableName = 'BlitzResults';
GO

Between the last two runs, Auto-Shrink got fixed and Page Verification is new. Everything else should be the same. Three runs, each with its own CheckDate:

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

Step 5: Now We Install Claude Code

I'm using the Claude desktop app. It includes Claude Code, so there's no separate CLI install, but it does require a paid subscription (Claude Code overview). Brent recommends starting with the terminal version instead, because its documentation is much better (Using Claude Code with SQL Server). I wanted to keep the first part simple, so I went with the app.

Download, install, sign in. There's no password to remember -- you sign in with the email of your Claude.ai account and you'll receive an emailed login link. Claude Code is the </> icon at the top left, next to the chat icon.

You need to give Claude a project folder. I created C:\BlitzApp. Everything the agent writes goes into this directory.

Step 6: The Ground Rules

Now the rules file for Claude. This is my CLAUDE.md, saved in C:\BlitzApp. Remember, it's guidance, not enforcement. The last rule comes straight from Grant's lessons: he said he'd now make the AI keep a log of what worked and what didn't.

# Ground rules

- Work only in this project folder.
- Do not connect to any database. I run every query.
- All SQL you write is SELECT-only, against DBA.dbo.BlitzResults.
- Every query has a TOP clause.
- Ask before running any command.
- Log every attempt in attempts.md: what you tried, what worked, what didn't.

Notice the second rule. In this part of the series, the agent writes and I run. It doesn't touch SQL Server at all.

Step 7: The First Prompt

In the Claude Code tab, I set the folder to C:\BlitzApp and the permission mode to Manual -- 'Always ask before making changes'. Then I gave it this, verbatim:

Read CLAUDE.md first.

The table DBA.dbo.BlitzResults holds sp_Blitz output from several runs. Every row from one run shares the same CheckDate. Columns: ID int, ServerName nvarchar, CheckDate datetimeoffset, Priority tinyint, FindingsGroup varchar, Finding varchar, DatabaseName nvarchar, URL varchar, Details nvarchar, QueryPlan xml, QueryPlanFiltered nvarchar, CheckID int.

Write a query that compares the two most recent runs and labels each finding:
- New: in the latest run only
- Fixed: in the previous run only
- Changed: in both runs, but Details differ
- Same: in both runs, Details unchanged

Match findings on CheckID and DatabaseName. DatabaseName can be NULL.

Save the query as report.sql. Do not run it. I will run it myself.

After about 45 seconds, it asked permission to write the file:

Note that orange arrow. 'Always allow' is the default, but I used the drop down and picked 'Allow once' instead. I want to approve every action myself -- at least for now.

Then I received this as Claude Code's response:

I saved the query as report.sql and logged this attempt in attempts.md. I haven't run it or connected to anything, so it's untested until you run it.
How it works:

* Picking the two runs: it takes the two most recent distinct `CheckDate` values.
* Matching: findings match on `CheckID` + `DatabaseName`, and two NULL DatabaseNames count as a match.
* Repeated findings: sp_Blitz can return several rows for the same CheckID and database. A plain join would pair every one of those rows with every other, so the query matches in two passes:
   1. Rows with the same key and identical Details are labelled Same.
   2. Remaining rows with the same key are paired in ID order and labelled Changed. Anything still unpaired is New (latest run only) or Fixed (previous run only).
* Output: status, priority, finding, CheckID, DatabaseName, previous and latest Details, and both row IDs. New findings sort first, then Changed, Fixed and Same.

Before you run it:

* TOP clauses: only the final SELECT (`TOP (1000)`) and the step that picks the run dates (`TOP (2)`) have one. I left TOP off the steps in between because a row limit there could silently drop rows and give them the wrong label. If you want TOP on every SELECT, tell me and I'll add it.
* Multiple servers: if the table holds runs from several servers with different CheckDates, "the two most recent runs" spans all servers. The query would then need to compare each server separately.
* Noisy Details: some sp_Blitz Details include dates, counts or sizes, so those findings will show as Changed on every run.

Look at that first 'Before you run it' item. My rules said every query gets a TOP clause. Claude didn't follow that rule everywhere, explained why, and asked before changing anything. Its reasoning was sound -- a TOP on an intermediate step could silently drop rows and mislabel findings.

The rules shaped the agent's work, but did not control every decision. That's guidance, not enforcement.

The query it wrote, verbatim:

-- report.sql
-- Compares the two most recent sp_Blitz runs in DBA.dbo.BlitzResults.
-- A run = all rows sharing one CheckDate. Findings match on CheckID + DatabaseName (NULL-safe).
--
--   New     : in the latest run only
--   Fixed   : in the previous run only
--   Changed : in both runs, Details differ
--   Same    : in both runs, Details unchanged
--
-- sp_Blitz can return more than one row for the same CheckID + DatabaseName.
-- To avoid a many-to-many join, rows are paired in two passes:
--   1. Pair rows whose Details are identical            -> Same
--   2. Pair the leftovers by key, in ID order           -> Changed
--      Anything still unpaired                          -> New / Fixed
-- When the key is unique per run, this is a plain one-to-one match.
--
-- SELECT-only. If only one run exists, every row comes back as New.

WITH Runs AS (
    SELECT TOP (2)
           CheckDate,
           ROW_NUMBER() OVER (ORDER BY CheckDate DESC) AS RunNum
    FROM DBA.dbo.BlitzResults
    GROUP BY CheckDate
    ORDER BY CheckDate DESC
),
Cur AS (   -- latest run
    SELECT b.ID, b.CheckID, b.DatabaseName, b.Priority, b.FindingsGroup, b.Finding, b.Details,
           ROW_NUMBER() OVER (PARTITION BY b.CheckID, b.DatabaseName, b.Details ORDER BY b.ID) AS DupNum
    FROM DBA.dbo.BlitzResults AS b
    JOIN Runs AS r ON r.CheckDate = b.CheckDate AND r.RunNum = 1
),
Prev AS (  -- previous run
    SELECT b.ID, b.CheckID, b.DatabaseName, b.Priority, b.FindingsGroup, b.Finding, b.Details,
           ROW_NUMBER() OVER (PARTITION BY b.CheckID, b.DatabaseName, b.Details ORDER BY b.ID) AS DupNum
    FROM DBA.dbo.BlitzResults AS b
    JOIN Runs AS r ON r.CheckDate = b.CheckDate AND r.RunNum = 2
),
-- Pass 1: same key, same Details
SamePairs AS (
    SELECT c.ID AS LatestID, p.ID AS PreviousID,
           c.CheckID, c.DatabaseName, c.Priority, c.FindingsGroup, c.Finding,
           p.Details AS PreviousDetails, c.Details AS LatestDetails
    FROM Cur AS c
    JOIN Prev AS p
      ON  (c.CheckID      = p.CheckID      OR (c.CheckID      IS NULL AND p.CheckID      IS NULL))
      AND (c.DatabaseName = p.DatabaseName OR (c.DatabaseName IS NULL AND p.DatabaseName IS NULL))
      AND (c.Details      = p.Details      OR (c.Details      IS NULL AND p.Details      IS NULL))
      AND c.DupNum = p.DupNum
),
-- Rows not paired in pass 1, renumbered within the key
CurLeft AS (
    SELECT c.ID, c.CheckID, c.DatabaseName, c.Priority, c.FindingsGroup, c.Finding, c.Details,
           ROW_NUMBER() OVER (PARTITION BY c.CheckID, c.DatabaseName ORDER BY c.ID) AS KeyNum
    FROM Cur AS c
    LEFT JOIN SamePairs AS s ON s.LatestID = c.ID
    WHERE s.LatestID IS NULL
),
PrevLeft AS (
    SELECT p.ID, p.CheckID, p.DatabaseName, p.Priority, p.FindingsGroup, p.Finding, p.Details,
           ROW_NUMBER() OVER (PARTITION BY p.CheckID, p.DatabaseName ORDER BY p.ID) AS KeyNum
    FROM Prev AS p
    LEFT JOIN SamePairs AS s ON s.PreviousID = p.ID
    WHERE s.PreviousID IS NULL
)
SELECT TOP (1000)
       x.Status, x.Priority, x.FindingsGroup, x.Finding, x.CheckID, x.DatabaseName,
       x.PreviousDetails, x.LatestDetails, x.PreviousID, x.LatestID
FROM (
    SELECT 'Same' AS Status,
           s.Priority, s.FindingsGroup, s.Finding, s.CheckID, s.DatabaseName,
           s.PreviousDetails, s.LatestDetails, s.PreviousID, s.LatestID
    FROM SamePairs AS s

    UNION ALL

    -- Pass 2: leftovers with the same key are Changed; unpaired rows are New or Fixed
    SELECT CASE WHEN c.ID IS NOT NULL AND p.ID IS NOT NULL THEN 'Changed'
                WHEN c.ID IS NOT NULL                      THEN 'New'
                ELSE                                            'Fixed'
           END,
           COALESCE(c.Priority,      p.Priority),
           COALESCE(c.FindingsGroup, p.FindingsGroup),
           COALESCE(c.Finding,       p.Finding),
           COALESCE(c.CheckID,       p.CheckID),
           COALESCE(c.DatabaseName,  p.DatabaseName),
           p.Details, c.Details, p.ID, c.ID
    FROM CurLeft AS c
    FULL OUTER JOIN PrevLeft AS p
      ON  c.KeyNum = p.KeyNum
      AND (c.CheckID      = p.CheckID      OR (c.CheckID      IS NULL AND p.CheckID      IS NULL))
      AND (c.DatabaseName = p.DatabaseName OR (c.DatabaseName IS NULL AND p.DatabaseName IS NULL))
) AS x
ORDER BY CASE x.Status WHEN 'New' THEN 1 WHEN 'Changed' THEN 2 WHEN 'Fixed' THEN 3 ELSE 4 END,
         x.Priority, x.CheckID, x.DatabaseName, x.LatestID, x.PreviousID;

I ran it as BlitzReader, the fenced login:

USE DBA;
GO
EXECUTE AS LOGIN = 'BlitzReader';

-- report.sql

REVERT;

The results:

Check the Status column. Row 1 is New: Page Verification. Row 2 is Fixed: Auto-Shrink. Everything else is the Same -- exactly the two changes I made in Step 4.

What's Next

I'm leaving DBA, BlitzResults and BlitzReader in place for Part 2, where I'll schedule sp_Blitz and give Claude Code its own connection as BlitzReader, so it can query the results directly.

In Part 3, I'll compare what the agent says it did with SQL Server's own record of what Claude actually ran. Because I want to know what it really did, not just what it tells me it did.

More to Read

Grant Fritchey: How I built an app just by talking to an AI LLM (the series)
SQL Server First Responder Kit on GitHub
Brent Ozar: sp_Blitz
Brent Ozar: Using Claude Code with SQL Server and Azure SQL DB
Claude Code: Configure permissions

Thursday, October 15, 2026

SQL Server CHOOSE(): Simple Enough, Until It Isn't

SQL Server has quite a few built-in functions that don't get much attention, and CHOOSE() is one of them. It's been around since SQL Server 2012, but I don't really see it used too often.

The function is straightforward. Give it a position and a list of values, and it returns the value at that position. In some cases, it can eliminate a lengthy CASE expression and make the query much easier to read.

There are a few things worth knowing before using it, though. The positions start at 1, not 0, an invalid position returns NULL, and SQL Server's data type precedence rules still apply. Those details matter, particularly when the position comes from data rather than a value you've supplied yourself.

Let's look at where CHOOSE() makes sense, how it compares with CASE, and a few examples where the results might not be what you expected.

What CHOOSE() Does

The syntax is simple: CHOOSE(index, value1, value2, ...). The index identifies which item in the list to return.

SELECT
    CHOOSE(1, 'Development', 'Test', 'Production') AS Environment1,
    CHOOSE(2, 'Development', 'Test', 'Production') AS Environment2,
    CHOOSE(3, 'Development', 'Test', 'Production') AS Environment3;

For a simple positional selection, CHOOSE() can be easier to read than a CASE expression. Both return the same result when the index is 1, 2, or 3. Here's a quick comparison using an EnvironmentID of 2:

DECLARE @EnvironmentID int = 2;

SELECT
    CASE @EnvironmentID
        WHEN 1 THEN 'Development'
        WHEN 2 THEN 'Test'
        WHEN 3 THEN 'Production'
    END AS CaseResult;

SELECT CHOOSE(@EnvironmentID, 'Development', 'Test', 'Production') AS ChooseResult;

You can change @EnvironmentID to 1 or 3 and see that both expressions still return the same result.

That's where CHOOSE() earns its place. It can make a straightforward positional lookup compact without making it difficult to follow. But it isn't a replacement for every CASE expression, especially when conditions are more complex or the values aren't sequential.

The Index Starts at 1

The first detail to remember is that CHOOSE() uses a 1-based index.

Suppose a table stores status codes beginning at zero. A CASE expression might look like this:

CASE StatusID
    WHEN 0 THEN 'Unknown'
    WHEN 1 THEN 'Active'
    WHEN 2 THEN 'Inactive'
    WHEN 3 THEN 'Temp'
END

Replacing it directly with CHOOSE(StatusID, 'Unknown', 'Active', 'Inactive', 'Temp') would not produce the same results. Here's a small example using rows rather than individual function calls:

SELECT
    s.StatusID,
    CHOOSE(s.StatusID, 'Unknown', 'Active', 'Inactive', 'Temp') AS DirectChoice,
    CHOOSE(s.StatusID + 1, 'Unknown', 'Active', 'Inactive', 'Temp') AS CorrectedChoice
FROM (VALUES (0), (1), (2), (3)) AS s(StatusID);

Adding 1 to the index works here because the codes are consecutive and start at zero. If the codes aren't consecutive, or their meaning can change, a CASE expression or a proper lookup table is generally a better fit.

What Happens Outside the List?

If the index is less than 1 or greater than the number of values supplied, CHOOSE() returns NULL. A NULL index also returns NULL.

SELECT
    CHOOSE(0, 'Dev', 'Test', 'Prod') AS IndexZero,
    CHOOSE(4, 'Dev', 'Test', 'Prod') AS PastTheEnd,
    CHOOSE(-1, 'Dev', 'Test', 'Prod') AS NegativeIndex,
    CHOOSE(NULL, 'Dev', 'Test', 'Prod') AS NullIndex;

That isn't necessarily a problem. A CASE expression without a matching branch or an ELSE also returns NULL. The important thing is to decide whether NULL is an acceptable result for the data you're working with. If you need an explicit fallback, CASE makes that very clear.

What If the Index Has a Decimal?

CHOOSE() expects an index that can be converted to INT. A numeric value with a fractional portion is converted to an integer, which truncates rather than rounds the value.

SELECT
    CHOOSE(2.9, 'Development', 'Test', 'Production') AS SelectedEnvironment;

The index becomes 2, so the second item is returned. That's predictable once you know the conversion rule, but worth checking if the index is calculated rather than stored as an integer.

Data Type Precedence Still Applies

The values supplied to CHOOSE() don't have to be written with the same data type, but SQL Server must determine a single return type. It uses the type with the highest precedence among the supplied values, just as it does for a CASE expression.

SELECT
    CHOOSE(2, 100, '200', '300') AS NumericChoice; 

In that last example, int has higher precedence than varchar, so the expression's result type is INT. The selected value, 100, is already an integer.

Now select a string that cannot be converted to an integer:

SELECT
    CHOOSE(2, 100, 'Active', 'Inactive') AS MixedTypes;

The second value is selected, but SQL Server has determined that the result must be an int. Converting 'Active' to INT fails. The lesson isn't that CHOOSE() can't handle mixed types; it's that implicit conversion rules still matter. When the values represent the same kind of information, use consistent data types.

When Would I Use CHOOSE()?

I like CHOOSE() for a short, fixed list when the index is known to be within range. One example is selecting a month label from a month number:

SELECT
    CHOOSE(MONTH(GETDATE()),
           'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
           'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec') AS MonthAbbreviation;

MONTH() returns a number from 1 through 12 for a valid date, so its range lines up with the twelve items in the list. For this kind of small, fixed selection, CHOOSE() is concise and easy to understand.

For status codes with gaps, complicated conditions, or values maintained as business data, I'd use CASE or a lookup table instead. And if you're converting an existing CASE expression to CHOOSE(), make sure the numeric values really do correspond to positions in the list.

CHOOSE() isn't complicated, and there's no reason to avoid it. For a short, fixed list of values, it can be considerably easier to read than a CASE expression. Like most T-SQL functions, it has a few behaviors worth understanding, but once you know them, it's another clever option to use.

More to Read

MSFT -- CHOOSE (Transact-SQL)
MSFT -- IIF (Transact-SQL)
MSFT -- CASE (Transact-SQL)
MSFT -- Data type precedence (Transact-SQL)

When AI Gets the Query Wrong and SQL Server Doesn't Complain

SQL Server will tell you when a column doesn't exist. It won't tell you when you've selected the wrong one.

That's an important distinction when you're running T-SQL generated by AI. An invented column name might produce an immediate error, but a perfectly valid column can return perfectly valid data that has absolutely nothing to do with what you're looking for.

You asked the AI assistant for a query, it wrote one referencing a column called Email. The name looked right. The query looked right. And the whole answer arrived with the same confidence you'd expect from someone who knew your database. So you ran it.

Pinal Dave wrote this week about why AI sounds confident when it is wrong -- that the tone of an answer tells you nothing about whether it is true, and that a model with no schema in front of it will invent a column name like Email because that is what tables usually have. He is right. But his post doesn't get into how you catch it in T-SQL, and that is what I want to show here.

An AI-generated query can get a column wrong in two very different ways, and the more dangerous one won't actually produce an error. We'll start with the easy one.

The column does not exist

Here is a CustomerOrders table where there is no Email column in it. The email is actually in another column named ContactEmail, but the AI didn't know that and guessed incorrectly:

SELECT CustomerID, FullName, Email
FROM dbo.CustomerOrders;

Of course, SQL Server stops you cold:

This is the good outcome. The guess was wrong, the column doesn't exist, and SQL Server rejects the query with an error. In this example, nothing was changed. The mistake was immediately obvious and you can correct it easily.

If only every AI-generated query made its mistakes this obvious.

The problem is you only get that error if the invented name happens not to exist. So before you run anything an AI assistant wrote, spend one query confirming what the table actually has:

SELECT c.name AS column_name,
       t.name AS data_type,
       c.max_length,
       c.is_nullable
FROM sys.columns c JOIN sys.types t 
  ON c.user_type_id = t.user_type_id
WHERE c.object_id = OBJECT_ID('dbo.CustomerOrders')
ORDER BY c.column_id;

There is the truth about this table, straight from the catalog. No Email. That column is ContactEmail, and now you know it, because you checked the schema instead of trusting AI's guess. You can also verify the column details in INFORMATION_SCHEMA.COLUMNS, but the catalog views expose more complete metadata. Either way, you're checking the actual table definition rather than relying on AI's assumptions.

If that were the whole problem, you could rely on Msg 207 to protect you and move on. But no. It's not that easy.

The dangerous case: the column exists - but it is the wrong one

Look at that column list again. There are two date columns: OrderDate and ShipDate. Now suppose you asked for a report of when each order shipped, and the AI assistant -- again, without your schema -- reached for the column that sounds like the obvious one:

SELECT CustomerID, FullName, OrderDate AS [When it shipped (per AI)]
FROM dbo.CustomerOrders;

No error. The query ran, returned data, and handed you a ship-date report -- except none of those are ship dates. They are order dates. OrderDate is a real column, so nothing stopped the query. It simply answered the wrong question in the same confident format as the right one. This is the kind of result that ends up in a presentation, an invoice, or an SLA report, and nobody thinks twice about it. Why would they? The query ran, the data looked right, and SQL Server didn't complain. Except the answer was wrong.

Catching this one takes the same habit, carried one step further. The name checked out, so the name is not the test. The values are. Put the two date columns side by side and the gap is immediately visible:

SELECT CustomerID, FullName, OrderDate, ShipDate, 
DATEDIFF(DAY, OrderDate, ShipDate) AS days_order_to_ship
FROM dbo.CustomerOrders;

Every order shipped after it was placed -- nine days later, seven, three. The first report used OrderDate for when it was shipped. ShipDate was not even in the query. Looked reasonable, sure. But it was dead wrong. That is the failure to fear. Not the one that throws an error, but the one that runs clean and gives you bad data -- with the same calm voice that it uses for the correct answers.

The habit that catches both

Pinal's point is that confidence is a writing style, not evidence. On the server, that turns into something you can actually do. Before you trust SQL written by AI, ask the catalog two questions, in this order:

QuestionHow
Do these columns exist?Check every name against sys.columns for the target table
Do they mean what the query assumes?Check the data types and read the values before trusting the result

The first question catches the loud mistakes -- the invented Email that throws Msg 207. The second addresses the quiet ones -- the real OrderDate standing in for ShipDate, which SQL Server has no reason to reject. Both illustrate why successful execution is not proof of a correct answer.

When an AI assistant doesn't have access to your schema, it works from assumptions about what your database probably looks like. And even when it gets the column names right, that doesn't mean it understands what those columns represent.

Checking the schema is a good start, but it isn't enough. You need to verify that the query is returning the data you actually asked for, not simply that it executes without error.

A query that fails is often less dangerous than a query that succeeds.

Just because SQL Server ran it doesn't mean AI got it right.

More to Read

Pinal Dave -- Why AI Sounds Confident When It's Wrong
MSFT -- sys.columns (Transact-SQL)
MSFT -- INFORMATION_SCHEMA.COLUMNS (Transact-SQL)
MSFT -- OBJECT_ID (Transact-SQL)

Monday, October 12, 2026

Legacy SQL Server ?? Microsoft Says It Isn’t. Its Partner Slide Said Otherwise.

A Microsoft partner slide put SQL Server under the heading '1st party legacy'. Brent Ozar flagged it, and a day later Microsoft said SQL Server isn't legacy. Many users saw the slide and they are already asking what it means. Here's what actually happened, what Microsoft shipped, and why that "legacy" label deserves more scrutiny than Microsoft's explanation alone.

The Slide

FabCon and SQLCon Europe ran September 28 to October 1, 2026 in Barcelona. At a partner day during the event, a slide was photographed and shared on LinkedIn. Brent posted it on September 30 in I'm Just Gonna Leave This Here.

One column reads "1st party legacy". The line under it reads "Modernize every SQL Server". Microsoft is the first party. Brent zoomed in on that pair of columns and called it what it reads like: Microsoft telling its partners that SQL Server is a legacy database.

That's a pretty significant label to put on a product that Microsoft released a new major version of less than a year ago. In enterprise IT, "legacy" generally suggests aging technology that organizations should be planning to replace or retire. It doesn't mean a supported, actively developed database platform simply because it isn't running in Azure. There's a difference, and Microsoft should know better than to blur it in a presentation to its own partners.

Image: Brent Ozar, from a slide shared on LinkedIn.

The post drew more than 40 comments. Postgres came up early. So did government and regulated shops that are required to keep data on-prem. Brent asked the obvious question -- if SQL Server is legacy, and the next step is Azure SQL DB or Fabric, what exactly are customers running in AWS supposed to do?

Microsoft's Response

On October 1, Anna Hoffman of Microsoft replied in the comments. She said the wording on the partner slide was poor, and that the two lines described separate modernization motions, not a designation of SQL Server. Then she said it plainly:

"Microsoft does not consider SQL Server a legacy product."

Brent wasn't done. He pointed back to the slide's own wording and asked Microsoft what it should have said instead. That's where the thread stands.

Both can be true. The product team can genuinely consider SQL Server a modern, actively developed platform while Microsoft's partner messaging encourages customers to move toward Azure. There's nothing inherently wrong with that strategy, but calling SQL Server "legacy" is a very different message from encouraging customers to modernize their existing deployments.

A migration strategy is not a product lifecycle announcement. Let's look at what Microsoft actually shipped.

What Microsoft Shipped

The same week, Microsoft published its SQLCon Barcelona announcements. The first section is titled "Running data and AI on infrastructure you control", and it walks through TDE in 2008, Always Encrypted in 2016, and Ledger in 2022 as decades of SQL Server investment. That is not how you talk about a legacy product. The announcements included:

SQL Server on Azure Local - GA
Both connected and disconnected operations. Disconnected means a local control plane for environments where cloud connectivity is unavailable or restricted. Microsoft pitched it squarely at digital sovereignty. That is SQL Server running on infrastructure you control. Announcement

Agent Mode for GitHub Copilot in SSMS - GA
SQL Projects and Schema Compare in SSMS went GA with it. You don't keep building out the client tool for a product you're retiring.

Azure SQL Database Hyperscale
160 and 192 vCore Premium-series GA, serverless auto-pause in preview, DiskANN vector indexes GA, and a new Azure Arc migration path to Hyperscale in preview.

Database Hub in Microsoft Fabric - Public Preview
Inventory, health, performance and security signals across your database estate, with database agents for SQL and PostgreSQL.

Read that list honestly. There is one on-prem GA and one tooling GA. The rest is Azure and Fabric. The marketing energy is pointed at the cloud, but the engine is still getting investment.

The servicing cadence tells the same story. SQL Server 2025 has had nine cumulative updates in 2026, one a month since January. CU9 (KB5122048) shipped September 15, 2026 as build 17.0.5005.3. One week earlier, security updates shipped for both the GDR branch (KB5122770) and the CU branch (KB5122769). That's an active servicing commitment to Microsoft's current database engine, not evidence of a product being phased out.

The Support Calendar Doesn't Say Legacy

Slides change. Lifecycle commitments are published. These are straight from Microsoft Learn:

Version Mainstream
End
Extended
End
SQL Server 2025 1/7/2031 1/7/2036
SQL Server 2022 1/12/2028 1/12/2033
SQL Server 2019 3/1/2025 1/9/2030
SQL Server 2017 10/12/2022 10/13/2027

Microsoft Learn lists each end date at 6:59:59 AM Pacific Time.

SQL Server 2025 is supported into 2036. That's a decade of committed patches for the version that shipped last November. Whatever the slide meant, that date is the one your contracts and your compliance people care about.

The calendar that actually matters right now is the other end. SQL Server 2016 reached end of support on July 14, 2026. SQL Server 2017 is next, in October 2027, and Brent posted a one-year countdown on October 12. If you're running either one, that is your legacy problem. Not the slide.

Know Where You Stand

Run this on each instance. It returns the version and current update level, so you can line it up against the table above.

SELECT
    @@SERVERNAME ServerName,
    CASE PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS NVARCHAR(128)), 4)
        WHEN '17' THEN 'SQL Server 2025'
        WHEN '16' THEN 'SQL Server 2022'
        WHEN '15' THEN 'SQL Server 2019'
        WHEN '14' THEN 'SQL Server 2017'
        WHEN '13' THEN 'SQL Server 2016'
        WHEN '12' THEN 'SQL Server 2014'
        WHEN '11' THEN 'SQL Server 2012'
        ELSE 'Older - check ProductVersion' END VersionName,
    SERVERPROPERTY('ProductVersion') ProductVersion,
    SERVERPROPERTY('ProductLevel') ProductLevel,
    SERVERPROPERTY('ProductUpdateLevel') UpdateLevel,
    SERVERPROPERTY('Edition') Edition;

Anything that comes back 2016 or older is already out of support. Anything that comes back 2017 has about a year. If it comes back 2025, compare ProductVersion against the build list. Mine says CU7. At the time of writing, the latest is CU9, 17.0.5005.3. Even a supported version can be behind. Start there.

If You're Not in Azure

A lot of SQL Server runs on-prem, in colos, and on AWS. None of those customers were in the room for that partner slide. All of them will hear about it.

Brent asked what AWS customers are supposed to do. AWS already answered. On July 21, 2026, Amazon RDS for SQL Server added SQL Server 2025 for Enterprise, Standard, and Developer editions. Existing RDS instances can upgrade by modifying the DB engine version. AWS supporting Microsoft's latest SQL Server release reinforces something that shouldn't need explaining: running SQL Server outside Azure doesn't make it legacy.

My advice to clients is simple. Don't make a platform decision off a partner slide or a blog comment. Make it off the lifecycle dates, your licensing, your RPO/RTO, and what your applications actually support. If that leads to Azure, fine. If it leads to SQL Server 2025 on the hardware you already own, Microsoft has committed to supporting that product through January 2036.

Microsoft is entitled to promote its cloud platforms, and customers are entitled to choose something else. What shouldn't get lost in that conversation is the distinction between modernizing a SQL Server environment and replacing SQL Server because someone decided to call it legacy.

SQL Server isn't legacy. SQL Server 2016 is.

More to Read

Brent Ozar: I'm Just Gonna Leave This Here
Microsoft: SQLCon Barcelona 2026 announcements
Microsoft: SQL Server on Azure Local is now generally available
Microsoft Learn: SQL Server 2025 build versions
Microsoft Learn: SQL Server 2025 lifecycle
Microsoft: SQL Server 2016 end of support is here
Brent Ozar: SQL Server 2017 Support Ends One Year From Today
AWS: Amazon RDS for SQL Server now supports SQL Server 2025

Thursday, October 8, 2026

Your New REGEXP_LIKE Just Turned a Seek Into a Scan

SQL Server 2025 finally gave us native regex, and everyone is swapping their old LIKE patterns for the shiny new REGEXP_LIKE. Microsoft documents anchored REGEXP_LIKE patterns as SARGable, meaning they can potentially benefit from index access. I anchored mine with '^', but SQL Server scanned a million rows anyway.

March 2025, Brent said don't get too excited.... Here is the measurement that suggests things remain the same 18 months later.

The Setup

One table, one million rows, one index on the column we search. Only 100 of those million rows start with 'Smith' -- that matters, because a rare value is exactly the case an index seek is built to win. Compatibility level 170 is required. REGEXP_LIKE does not exist below that CL.

IF DB_ID('RegexDemo') IS NULL
CREATE DATABASE RegexDemo;
GO
USE RegexDemo;
ALTER DATABASE CURRENT SET COMPATIBILITY_LEVEL = 170;

DROP TABLE IF EXISTS dbo.Customers;
GO
CREATE TABLE dbo.Customers
(
    CustomerID INT IDENTITY PRIMARY KEY,
    LastName VARCHAR(50) NOT NULL
);

/* 1,000,000 rows. Only 100 start with 'Smith' -- that is the point.
   Everybody else starts with 'Acct'. Hard cap in TOP. */
;WITH Tally AS
(
    SELECT TOP (1000000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
    FROM sys.all_columns a CROSS JOIN sys.all_columns b
)
INSERT dbo.Customers (LastName)
SELECT CASE WHEN n % 10000 = 0
            THEN 'Smith' + CAST(n AS VARCHAR(10))   /* 100 rows */
            ELSE 'Acct'  + CAST(n AS VARCHAR(10))   /* the other 999,900 */
       END
FROM Tally;

CREATE NONCLUSTERED INDEX idx_Customers_LastName
    ON dbo.Customers (LastName);

The SELECT below returns only CustomerID and LastName. CustomerID is the clustered key, so it already rides inside the nonclustered index -- the index covers the query, gets rid of any key lookups, and the logical reads measure one thing cleanly: did we seek, or did we scan?

The Baseline: LIKE Seeks

Find every customer whose last name starts with Smith, the old way. STATISTICS IO on, actual execution plan on:

SET STATISTICS IO ON;
GO

SELECT CustomerID, LastName
FROM dbo.Customers
WHERE LastName LIKE 'Smith%';

Index Seek. Three logical reads to pull 100 rows out of a million. That's a number to remember.

The Regex The Docs say Should Have a Chance to Seek

Now the regex version. Microsoft's documentation identifies anchored patterns as SARGable, so I used ^Smith to express the same prefix search as LIKE 'Smith%'. The question is whether SQL Server will now take advantage of the existing index.

SELECT CustomerID, LastName
FROM dbo.Customers
WHERE REGEXP_LIKE(LastName, '^Smith');

Index Scan. 2,979 logical reads for the same 100 rows. The anchor is right there at the front of the pattern, the documentation says that makes it SARGable, and the optimizer scanned the entire index anyway.

The Scoreboard

Query Operator Logical reads
LIKE 'Smith%' Index Seek 3
REGEXP_LIKE(LastName, '^Smith') Index Scan 2,979

Same 100 rows. Same indexed column. The anchored regex cost roughly a thousand times the reads of the LIKE it was meant to replace.

Why 'SARGable' Didn't Save Me

SARGable means the optimizer is allowed to use a seek. It does not mean that it will. That choice comes down to cost, and cost comes down to how many rows the optimizer thinks it will find.

That is where things get interesting. SQL Server chose an index scan even though the pattern was anchored and the nonclustered index covered everything the query needed. Microsoft's documentation includes two query hints, ASSUME_FIXED_MIN_SELECTIVITY_FOR_REGEXP and ASSUME_FIXED_MAX_SELECTIVITY_FOR_REGEXP, specifically to adjust the default selectivity assumptions for regex predicates. That tells us selectivity estimation is an important consideration, but it doesn't prove that cardinality estimation caused this particular scan. What the execution plan does prove is that the optimizer didn't turn this anchored regex into the index seek we saw with LIKE.

The Takeaway

Do not swap LIKE for REGEXP_LIKE on a prefix search assuming the index will behave the same way. On my v2025 instance, the anchored regex scanned the index and required 2,979 logical reads to return the same 100 rows that the LIKE retrieved with just 3. That doesn't mean every anchored regex will scan, but it clearly demonstrates that SARGability alone is no guarantee of an efficient execution plan. REGEXP_LIKE earns its place where LIKE genuinely cannot do the job -- character classes, quantifiers, real pattern matching. For 'starts with Smith', the traditional LIKE 'Smith%' remains the better choice in this test.

Test it yourself before you trust it, and watch the reads. Rowcount is the same either way. Only the reads tell us what it costs.

/* Cleanup */
USE master;
DROP DATABASE IF EXISTS RegexDemo;

More to read

MSFT -- REGEXP_LIKE (Transact-SQL)
Brent Ozar -- T-SQL Has Regex in SQL Server 2025. Don't Get Too Excited.

Wednesday, October 7, 2026

SQL Copilot's Read-Only Mode Wasn't Read-Only.

I was in Italy for a month. While I was gone, somebody got sysadmin out of SQL Copilot with a variable.

The vulnerability is CVE-2026-65669, the SSMS 22 Copilot bug I wrote about early September. The full write-up went public on September 30th, and the detail that stuck with me is how Copilot's 'read-only mode' was enforced. It wasn't a permission. It was a regex blocklist.

That blocklist is the same control we have watched fail against SQL injection for twenty years. Before I get to Copilot, let's build one here and see it fail.

The Setup

First, a table to protect:

USE tempdb;
GO

DROP TABLE IF EXISTS dbo.names;
GO
CREATE TABLE dbo.names (UserName SYSNAME NOT NULL);
INSERT dbo.names (UserName) VALUES (N'rebecca');
GO

Now a stored procedure that runs whatever query string you hand it, as long as it decides the string is read-only. It decides by scanning the text for keywords like INSERT, UPDATE and DELETE, and refusing the query if it finds one. I am building that check with LIKE so this runs on any version of SQL Server you have -- v2025 is not required.

CREATE OR ALTER PROCEDURE dbo.usp_ReadOnlyGuard
    @query NVARCHAR(MAX)
AS
BEGIN
    SET NOCOUNT ON;

    /* 'Read-only' enforcement: reject the dangerous keywords. */
    IF @query LIKE '%INSERT %'
    OR @query LIKE '%UPDATE %'
    OR @query LIKE '%DELETE %'
    OR @query LIKE '%DROP %'
    OR @query LIKE '%ALTER %'
    OR @query LIKE '%EXEC %'
    OR @query LIKE '%EXECUTE %'
    OR @query LIKE '%MERGE %'
    OR @query LIKE '%TRUNCATE %'
    BEGIN
        RAISERROR('Blocked: read-only mode permits SELECT only.', 16, 1);
        RETURN;
    END

    EXEC (@query);   /* approved -- run it */
END
GO

There is no AI here yet. This proc is standing in for it. You hand it a string, it checks the string and runs it -- exactly like Copilot's tool did. A legitimate read-only query goes straight through:

EXEC dbo.usp_ReadOnlyGuard @query = N'SELECT UserName FROM dbo.names;';

Purely SELECT, it ran exactly as intended. Now let's try two obvious attacks. A direct write, and a stored procedure call, both of which should be blocked.

EXEC dbo.usp_ReadOnlyGuard @query = N'UPDATE dbo.names SET UserName = N''x'';';

EXEC dbo.usp_ReadOnlyGuard @query = N'EXEC sp_who;';

Both blocked. Each one hit a keyword on the list, the guard stopped it and returned this:

Check your data. Confirm nothing moved:

SELECT UserName FROM dbo.names;

This is where people relax. The dangerous words were right there in the strings, the filter saw them, the writes never happened. Read-only mode works.

But no. It doesn't.

Start With the EXEC Check

The guard checks for nine keywords, and my query has to get past every one of them. Let's start with the execution check.

That check looks for EXEC followed by a space. But T-SQL lets me call EXEC(@s) with no space -- a parenthesis instead -- and it runs the same. So '%EXEC %' should miss it. Here is a harmless SELECT run that way, so there is nothing to block except the EXEC call itself:

EXEC dbo.usp_ReadOnlyGuard
     @query = N'DECLARE @s NVARCHAR(200) = N''SELECT UserName FROM dbo.names;''; EXEC(@s);';

No errors. It ran.

The guard never recognized an 'EXEC' because EXEC( has no space after it. That is the execution check beaten. One down.

Now Hide the Verb

Beating the EXEC check alone is not enough. This one is for 'UPDATE' which is also in the list and the guard flags '%UPDATE %'. So in this one, I will hide it by building it from two pieces -- N'UP' + N'DATE' -- which SQL Server joins into UPDATE only at runtime, long after the guard has checked the text.

Put both tricks together -- the no-space EXEC( and the split verb:

EXEC dbo.usp_ReadOnlyGuard
     @query = N'DECLARE @s NVARCHAR(200) = N''UP'' + N''DATE dbo.names SET UserName = N''''gotcha'''';''; EXEC(@s);';

No error. Check your data:

SELECT UserName FROM dbo.names;

This is the string the guard checked once SQL Server resolved the outer quotes:

DECLARE @s NVARCHAR(200) = N'UP' + N'DATE dbo.names SET UserName = N''gotcha'';'; EXEC(@s);

Not one blocked keyword in it. No UPDATE -- that word does not exist until UP and DATE join at runtime. No EXEC with a space -- it is EXEC(. The guard checked it, matched nothing, and ran it -- and your data changed.

That is a write in read-only mode, and UPDATE is the gentle version. The guard just ran a string I built at runtime, and that string can hold anything -- changing a column today, adding my login to the sysadmin role tomorrow. I could go on. The guard is reading one version of the string, and SQL Server is running another.

The researcher who found the Copilot bug got to the same place through a different door -- DECLARE @p sysname='sp_executesql'; EXEC @p, running the procedure through a variable so the blocklist never saw its name. Different keyword, same lesson. Once any route to dynamic execution survives the filter, read-only is over.

Why This Is the Whole Problem

Here is the table I wish had been on a wall in Redmond.

Control What it checks Gets talked around?
Keyword blocklist The text of the string Yes -- rename it, split it, wrap it
A GRANT What the login is allowed to do No

A blocklist is a bouncer at the door who only knows the names on his list. A permission is a locked door. The researcher's own fix says it: read-only for an AI agent has to be 'a security invariant (like a permission)', not a model instruction or a fragile keyword filter.

If my usp_ReadOnlyGuard had instead executed under a properly restricted security context, with SELECT access but no effective permissions to modify data or elevate privileges, my bypasses would have failed immediately. Not because the string looked wrong, but because the write was never permitted in the first place. End of story. The filter asks 'does this look dangerous?' The permission model asks 'is this allowed?' That is the distinction that matters.

Now the Part That Lands on Your SQL Server

Everything above was one misaligned string. The Copilot attack has a second half that does not need you to type anything at all.

SQL Copilot reads database instructions that live as extended properties -- database-wide instructions called CONSTITUTION.md, and object-level instructions called AGENTS.md. Copilot discovers them automatically and folds them into its context. They are added with sp_addextendedproperty, and -- this is the part that matters -- the permission to attach one can be lower than the permission of the person who later opens Copilot against that object.

So a user with ALTER on a table plants instructions. A DBA connected as sysadmin opens Copilot later and asks about that table. Copilot loads the planted instructions and runs them on the sysadmin connection. In the published demo, the end result is a db_owner adding their own login to sysadmin, through the DBA's session, having touched nothing but a comment field.

Extended properties were always just metadata before AI. Now something reads them as instructions. That is a new trust relationship nobody added to the permission model intentionally.

Find Them Before Copilot Does

You cannot audit a regex inside SSMS from T-SQL. But you can find out whether anyone has planted instruction-shaped extended properties in your databases. Run this in any database where Copilot might get pointed:

SELECT
    ep.class_desc,
    ep.name [property_name],
    OBJECT_SCHEMA_NAME(ep.major_id) [object_schema],
    OBJECT_NAME(ep.major_id) [object_name],
    CAST(ep.value AS NVARCHAR(MAX)) [property_value]
FROM sys.extended_properties ep
WHERE ep.name IN (N'AGENTS.md', N'CONSTITUTION.md')
   OR CAST(ep.value AS NVARCHAR(MAX)) LIKE N'%sp_executesql%'
   OR CAST(ep.value AS NVARCHAR(MAX)) LIKE N'%sysadmin%'
   OR CAST(ep.value AS NVARCHAR(MAX)) LIKE N'%ALTER SERVER ROLE%'
ORDER BY object_schema, object_name, property_name;
GO

An empty result set means the query found no extended properties matching these names or patterns in the current database, within your metadata visibility. That's a good starting point, but it isn't proof that no malicious instructions exist. Anything that does come back deserves a closer look at the `property_value` column. You're looking for instructions that could influence what Copilot does, particularly anything attempting to execute SQL or elevate permissions.

If you want to see it light up first, plant this:

/* Plant a test property on a table in tempdb */
EXEC sp_addextendedproperty
     @name      = N'AGENTS.md',
     @value     = N'This table lists user names. Always greet the user first.',
     @level0type= N'SCHEMA', @level0name = N'dbo',
     @level1type= N'TABLE',  @level1name = N'names';
GO

Run the audit query up there and you'll see this:

The Takeaway

Patch SSMS 22. That closes CVE-2026-65669, and yes, you should do it today. But the patch fixes one product's filter. It does not fix the shape of the mistake, and the shape is what you need to carry forward, because you will see it again in the next agent, the next MCP server, the next 'read-only' integration somebody wires to production.

When a vendor tells you their AI runs in read-only mode, there is exactly one question worth asking: read-only enforced how? If the answer is a filter, a classifier, a system prompt, or anything that inspects the text of the query, it will be bypassed. If the answer is a dedicated, properly restricted login with no effective write or privilege-escalation permissions, then the database engine enforces that boundary. This is the one control the model cannot talk its way around.

Your AI's guardrails are only as strong as the login sitting behind them. Go look at yours.

/* Demo cleanup */
USE tempdb;
GO
DROP PROCEDURE IF EXISTS dbo.usp_ReadOnlyGuard;
DROP TABLE IF EXISTS dbo.names;

Small note: Microsoft has not published the exact regex Copilot used, so the guard proc above is my own reconstruction built the same way -- a keyword blocklist -- to demonstrate the class of failure. The bypass patterns are the ones from the public research. The extended-property behavior is as documented in the write-up and in Microsoft's own admin-controls guidance.

More to read

Slides -- From SELECT to SYSADMIN: Hacking SQL Copilot in SSMS
MSRC -- CVE-2026-65669
MSFT -- Admin controls for GitHub Copilot in SSMS
sqlfingers -- This SQL Server CVE Isn't on Your SQL Server