Tuesday, August 11, 2026

Temp Tables: Foe When It's a Reflex, Friend When It's a Decision

T-SQL Tuesday

This month Jeff Taylor is hosting T-SQL Tuesday #201, and he came out swinging. His position, in his words: "reaching for a temp table should be the exception, not the reflex." His evidence is a client proc that pulled 250+ million rows into a #temp table to answer a one-row question, and his rewrite that did the same work against the existing indexes.

He's right about that proc. And I believe he's also right about the general shape of the problem. I've pulled that same pattern out of enough procedures to know it's real.

But he invited disagreement, and I have some. So I built a lab to find the case his argument doesn't cover.

The Claim I'm Testing

Jeff's argument rests on one idea: the base table already has the indexes and the statistics, so copying the data into tempdb is redundant work. Same data, worse container.

My counter is that the temp table isn't just a container. It's a plan boundary.

SQL Server optimizes one statement at a time. A CTE isn't a statement. It's a named subquery the Optimizer expands inline at every reference, so all of it compiles and executes as one plan. Writing to a #temp table ends the statement. That hard stop changes two things: how many times the same work gets done, and how big any single plan is allowed to get -- and neither of these has anything to do with where the rows are sitting. The second one matters more than it sounds like, because the decision to go parallel, and the DOP that comes with it, are made once for the whole statement.

So I designed a demo to be maximally unfair to myself. The base table gets a perfect covering index and full-scan statistics update. Everything Jeff says the Optimizer already has, it has. If the temp table still wins from there, it wins on something else entirely.

My evidence method for this post is logical reads. That's the number that carries the argument. Everything else in here is an observation.

The Lab

SQL Server 2025 RTM CU7, build 17.0.4065.4, Cardinality Estimation model 170. Same laptop as always -- 14 CPUs, 16GB RAM with 12GB allocated to SQL Server, NVMe storage. The demo code itself can be run in SQL Server 2016 and later.

Two tables, 50,000 customers and 2,000,000 orders. Row caps are literals in the TOP so the script can't run away from you.

USE master;
GO

CREATE DATABASE TempTableDemo;
GO
ALTER DATABASE TempTableDemo SET RECOVERY SIMPLE;
GO

USE TempTableDemo;
GO

/* child table first, then parent */
IF OBJECT_ID('dbo.tblOrders','U') IS NOT NULL DROP TABLE dbo.tblOrders;
IF OBJECT_ID('dbo.tblCustomers','U') IS NOT NULL DROP TABLE dbo.tblCustomers;
GO

CREATE TABLE dbo.tblCustomers
(
    CustomerID INT NOT NULL,
    CustomerName VARCHAR(100) NOT NULL,
    Region VARCHAR(20)  NOT NULL,
    CONSTRAINT pkcl_tblCustomers_CustomerID PRIMARY KEY CLUSTERED (CustomerID)
);
GO

CREATE TABLE dbo.tblOrders
(
    OrderID INT NOT NULL,
    CustomerID INT NOT NULL,
    OrderDate DATE NOT NULL,
    OrderTotal DECIMAL(10,2) NOT NULL,
    CONSTRAINT pkcl_tblOrders_OrderID PRIMARY KEY CLUSTERED (OrderID)
);
GO

/* 50,000 customers. hard cap in TOP. */
;WITH Tally AS
(
    SELECT TOP (50000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
    FROM sys.all_columns a CROSS JOIN sys.all_columns b
)
INSERT dbo.tblCustomers (CustomerID, CustomerName, Region)
SELECT n,
       'Customer ' + CAST(n AS VARCHAR(10)),
       CASE n % 4
            WHEN 0 THEN 'North'
            WHEN 1 THEN 'South'
            WHEN 2 THEN 'East'
            ELSE 'West'
       END
FROM Tally;
GO

/* 2,000,000 orders. hard cap in TOP.
   Multiplier 7919 is prime -- spreads orders across customers evenly. */
;WITH Tally AS
(
    SELECT TOP (2000000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
    FROM sys.all_columns a CROSS JOIN sys.all_columns b
)
INSERT dbo.tblOrders (OrderID, CustomerID, OrderDate, OrderTotal)
SELECT n,
       ((n * 7919) % 50000) + 1,
       DATEADD(DAY, -(n % 1095), CAST('2026-08-11' AS DATE)),
       CAST(((n % 500) + 10) AS DECIMAL(10,2))
FROM Tally;
GO

/* Give the base table exactly what Jeff argues it already has:
   the right covering index and clean statistics.  Nothing to blame later. */
CREATE NONCLUSTERED INDEX idx_tblOrders_CustomerID_OrderDate
ON dbo.tblOrders (CustomerID, OrderDate)
    INCLUDE (OrderTotal);
GO

UPDATE STATISTICS dbo.tblOrders WITH FULLSCAN;
UPDATE STATISTICS dbo.tblCustomers WITH FULLSCAN;
GO

The Question

Top 100 customers by trailing-twelve-month spend, restricted to those above the average, displayed next to the population average and maximum.

Nothing exotic. This is a typical dashboard query. The thing to notice is that it needs the same aggregate four separate times -- once to list, once for the average column, once for the maximum column, and once more in the WHERE clause.

Round 1: No Temp Table

SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO

/* lab server only -- don't run this on anything shared */
DBCC FREEPROCCACHE;
GO

;WITH CustomerTotals AS
(
    SELECT o.CustomerID,
           SUM(o.OrderTotal) AS TotalSpend,
           COUNT_BIG(*) AS OrderCount
    FROM dbo.tblOrders o
    WHERE o.OrderDate >= '2025-08-11'
    GROUP BY o.CustomerID
)
SELECT TOP (100)
       c.CustomerName,
       ct.TotalSpend,
       ct.OrderCount,
       (SELECT AVG(x.TotalSpend) FROM CustomerTotals AS x) AS AvgSpendAllCustomers,
       (SELECT MAX(y.TotalSpend) FROM CustomerTotals AS y) AS MaxSpendAllCustomers
FROM CustomerTotals ct JOIN dbo.tblCustomers c
  ON c.CustomerID = ct.CustomerID
WHERE ct.TotalSpend > (SELECT AVG(z.TotalSpend) FROM CustomerTotals AS z)
ORDER BY ct.TotalSpend DESC;
GO

Here's the return:

SQL Server parse and compile time: 
   CPU time = 0 ms, elapsed time = 2 ms.
DBCC execution completed. If DBCC printed error messages, contact your system administrator.

 SQL Server Execution Times:
   CPU time = 16 ms,  elapsed time = 39 ms.
SQL Server parse and compile time: 
   CPU time = 529 ms, elapsed time = 529 ms.

(100 rows affected)
Table 'tblOrders'. Scan count 60, logical reads 26328, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'Workfile'. Scan count 0, logical reads 0, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'tblCustomers'. Scan count 0, logical reads 32228, physical reads 0, page server reads 0, read-ahead reads 7, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 2203 ms,  elapsed time = 309 ms.

Completion time: 2026-08-10T10:03:58.1428260-05:00

Note the compile. 529 ms elapsed to build the plan against 309 ms elapsed to run it. This query spent longer being planned than being executed. On CPU it goes the other way, since execution burned 2,203 ms across 14 threads, but on the wall clock the compile won.

Round 2: The Temp Table

SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO

CREATE TABLE #CustomerTotals
(
    CustomerID INT NOT NULL PRIMARY KEY CLUSTERED,
    TotalSpend DECIMAL(19,2) NOT NULL,
    OrderCount BIGINT NOT NULL
);

/* one pass over the base table. that is the whole argument. */
INSERT #CustomerTotals (CustomerID, TotalSpend, OrderCount)
SELECT o.CustomerID,
       SUM(o.OrderTotal),
       COUNT_BIG(*)
FROM dbo.tblOrders o
WHERE o.OrderDate >= '2025-08-11'
GROUP BY o.CustomerID;

DECLARE @AvgSpend DECIMAL(19,2);
DECLARE @MaxSpend DECIMAL(19,2);

SELECT @AvgSpend = AVG(TotalSpend),
       @MaxSpend = MAX(TotalSpend)
FROM #CustomerTotals;

SELECT TOP (100)
       c.CustomerName,
       ct.TotalSpend,
       ct.OrderCount,
       @AvgSpend AS AvgSpendAllCustomers,
       @MaxSpend AS MaxSpendAllCustomers
FROM #CustomerTotals ct JOIN dbo.tblCustomers c
  ON c.CustomerID = ct.CustomerID
WHERE ct.TotalSpend > @AvgSpend
ORDER BY ct.TotalSpend DESC;
GO

Five sets of timings come back, but only three statements touch a table:

SQL Server parse and compile time: 
   CPU time = 0 ms, elapsed time = 0 ms.

 SQL Server Execution Times:
   CPU time = 0 ms,  elapsed time = 0 ms.
SQL Server parse and compile time: 
   CPU time = 0 ms, elapsed time = 1 ms.

 SQL Server Execution Times:
   CPU time = 0 ms,  elapsed time = 1 ms.
SQL Server parse and compile time: 
   CPU time = 6 ms, elapsed time = 6 ms.
Table 'tblOrders'. Scan count 15, logical reads 6582, physical reads 1, page server reads 0, read-ahead reads 6547, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'Workfile'. Scan count 0, logical reads 0, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 420 ms,  elapsed time = 127 ms.

(50000 rows affected)
SQL Server parse and compile time: 
   CPU time = 0 ms, elapsed time = 3 ms.
Table '#CustomerTotals_____________________________________________________________________________________________________000000000033'. Scan count 1, logical reads 187, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 16 ms,  elapsed time = 26 ms.
SQL Server parse and compile time: 
   CPU time = 31 ms, elapsed time = 37 ms.

(100 rows affected)
Table 'tblCustomers'. Scan count 0, logical reads 328, physical reads 1, page server reads 0, read-ahead reads 240, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table '#CustomerTotals_____________________________________________________________________________________________________000000000033'. Scan count 1, logical reads 187, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 47 ms,  elapsed time = 48 ms.

Completion time: 2026-08-10T10:04:49.4151381-05:00

The Scoreboard

Measurement Round 1
(no temp table)
Round 2
(#temp)
Ratio
tblOrders logical reads 26,328 6,582 4.00x
tblCustomers logical reads 32,228 328 98.3x
#CustomerTotals logical reads n/a 374
(187 x 2)
--
Worktable / Workfile 0 0 --
Total logical reads 58,556 7,284 8.04x
CPU time 2,203 ms 483 ms
(summed)
4.56x
Elapsed time 309 ms 202 ms
(summed)
1.53x

Eight times the reads to produce the same hundred rows, on a base table with the ideal index and freshly rebuilt statistics.

Three things in the plans explain that gap. One I expected, one I didn't, and one I had completely backwards.

Four Scans, Not One

Round 1's execution plan contains four separate Index Scan operators against tblOrders. All four hit idx_tblOrders_CustomerID_OrderDate. All four report 14 actual executions. All four read 668,681 rows.

Round 2's INSERT contains one. Same index, same 14 executions, same 668,681 rows.

26,328 divided by 6,582 is 4.0000. Not approximately four. Exactly four. Scan count agrees independently: 60 against 15.

This is the first mechanic. A CTE is a named subquery, not a stored result -- the Optimizer expands its definition inline everywhere you reference it, and nothing requires it to notice that all four copies would return the same rows. So it built all four and ran all four. The temp table built the answer once and read it back twice at 187 logical reads a pop, because the AVG and the MAX came out of a single scan.

And before anyone asks about the spool

The Optimizer has an out here. When it spots the same subtree repeated in a plan, it can run that work once, park the result in a worktable in tempdb, and have the other references read from it -- effectively building itself a temp table because I didn't. If that had happened, the four scans would have collapsed to one and there'd be no post.

It didn't happen. Neither plan contains a Spool operator of any kind. The STATISTICS IO output says the same thing from the other direction: Worktable and Workfile both sit at 0 logical reads in Round 1, which is exactly where a spool would have shown up.

The 98x I Didn't Predict

The tblCustomers number is bigger than the tblOrders number, and it has nothing to do with the four scans.

Both rounds seek tblCustomers. Same clustered index, same operator, scan count 0 on both. The difference is how many times:

Round Seek on
pkcl_tblCustomers_CustomerID
Actual
executions
Logical
reads
Round 1 Clustered Index Seek 15,568 32,228
Round 2 Clustered Index Seek 100 328

15,568 seeks against 100, to return the same hundred rows.

The cause is parallelism. Round 1 ran at DOP 14 with the TOP sitting above the Gather Streams operator. Each of the 14 threads sorted its own slice of the above-average customers and fed seeks into tblCustomers on demand, and 15,568 of those seeks went through before the TOP 100 was finally satisfied.

Round 2's final statement ran serial. 113 rows out of the Sort, 100 seeks. Done.

That difference is structural, and it's the part of this that I think gets overlooked. Look at the estimated subtree costs:

Statement Est. subtree cost DOP
Round 1, entire query 24.32 14
Round 2, INSERT 7.95 14
Round 2, AVG and MAX 0.23 1
Round 2, final SELECT 1.46 1

The temp table didn't just avoid three extra scans. It broke one expensive parallel statement into three cheap ones, and each of the three got its own plan. The INSERT still went parallel at DOP 14. The other two ran serial. Same plan shape as Round 1 -- Top, then Nested Loops, then Sort -- but one thread instead of 14, and it stops at 100.

The Part That Proved Me Wrong

Going in, my working theory was that the temp table would win because materializing gives the Optimizer a real row count where a chained estimate had degraded into a guess. The estimation argument. It's the one I've made verbally many times before.

The plans say otherwise. Compare the estimate to the actual for the above-average customer set, which is the row count that drives everything downstream:

Round Estimated Actual Miss
Round 1 (nested loops join) 24,997.4 24,994 0.01%
Round 2 (scan of #CustomerTotals) 15,000 24,994 40% low

Round 1's estimate was nearly perfect. Round 2's was exactly 30 percent of the 50,000 rows in the temp table -- SQL Server's fallback for an inequality when it can't know the value at compile time, which it can't, because @AvgSpend is a local variable.

The temp table version won by 8x on logical reads while carrying the worse cardinality estimate.

Better information did not produce the better plan. That kills the theory I started with, and it narrows what's left. The temp table won on the two things I described up top -- work that wasn't repeated, and plans small enough to stay serial. Nothing about the quality of the estimates, and nothing about where the rows were sitting.

Why Elapsed Time Will Lie to You

Reads went down 8x. The stopwatch went down 1.53x. Those two numbers describe the same pair of queries, and if you only look at one of them you'll reach the wrong conclusion.

Round 1 burned 2,203 ms of CPU to finish in 309 ms. That's 14 threads on an idle laptop, and it's the whole illusion. The work didn't shrink. It got spread across cores that had nothing else to do. A developer clocks both versions, sees 309 against 202, and decides the temp table isn't worth the bother. On an idle box that's a fair reading of the stopwatch.

Run it a thousand times a day on a production server and those cores are already busy. The reads don't care how many threads you have. Round 1 is still asking for eight times the pages and four and a half times the CPU to produce the same hundred rows, and the parallelism that made it look fast is the first thing to disappear under load.

Worth noting Round 2 won from behind. tblOrders in Round 1 shows 0 physical reads and 0 read-ahead -- fully cached. Round 2 shows 1 physical read and 6,547 read-ahead reads, meaning it was pulling pages off disk. It still won by 8x.

CPU and elapsed time are observations here, not evidence. The reads are the argument, but the gap between them is what gets temp tables ripped out of working code.

So, Friend or Foe?

Everything Jeff says the Optimizer already has, it had. The index was there and it was used. The estimates were better without the temp table. And the version without still did four times the reads on tblOrders, 98 times the reads on tblCustomers, and four and a half times the CPU.

Which supports what I'm saying about the temp table's value not being about where the rows live. The real value is that a temp table is a plan boundary. Everything else is a side effect.

Materializing forced the engine to solve three small problems instead of one big one. The aggregate got computed once instead of four times. The final SELECT ran serially, which is what collapsed 15,568 seeks down to 100. None of that required a single byte of the data to be sitting somewhere else.

So, my rule of thumb for when do I reach for a #temp table or not, and how do I decide?

I always have 3 questions: How many times will the result be read? How many times will it be changed? And how much of it am I moving to find out?

Read more than once? Materialize it. That's this demo. Four references, four evaluations, and no spool coming to rescue you. The break-even is lower than people think, and two references is often enough, because you're trading one write for one whole extra pass.

Changed more than once? Materialize it. If you're updating a working set in stages -- flag these rows, then recalculate those, then filter on what's left -- a CTE can't hold that for you. You can modify data through a CTE, but the CTE only exists for the one statement it's attached to, so nothing carries forward to the next step. Each stage rebuilds the set from scratch. This is the case Jeff's argument doesn't reach, because you're not copying data to search it. You're copying it to work on it.

One statement grown big enough that the plan is the problem? Split it. A 24-cost plan making a single parallelism decision for the whole tree can be worse than three small plans each making their own. This is the one nobody looks for, and it's what produced the 98x above.

Reading it once, start to finish? Leave it alone. This is Jeff's 250 million rows and he's right. One reference, one pass, no boundary needed. You paid for tempdb and bought nothing.

Staging every column? Stage keys instead. Also Jeff, also correct. Carry the identifiers and join back to the base table for the wide columns. The width of what you copy is a separate decision from whether you copy at all, and it's the one people skip.

Small result feeding a small operation? Stop thinking about it. A few hundred rows, one pass, no reuse. Temp table, table variable, CTE, derived table -- pick whichever reads best. The difference is noise and you have better things to tune. Most of the temp tables I find in production live here, which is why they never show up in anybody's slow query report and never get removed.

What ties these all together is that none of them are about size on its own. A 250-million-row set read once should stay where it is. A 50,000-row set read four times should not. Volume tells us what a mistake will cost -- it doesn't tell us whether you're making one.

Which means Jeff and I mostly agree. He's arguing against the reflex, and I'm not defending the reflex either. I'm defending the tool. 'Temp tables are used too freely' and 'temp tables are a legitimate Optimizer control' are both true, and the second one is why the first one happens.

Foe when it's a reflex. Friend when it's a decision.

Demo Cleanup

USE master;
GO

ALTER DATABASE TempTableDemo SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE TempTableDemo;
GO

Thanks to Jeff for hosting, and for picking a topic with a true argument in it.

More to Read

Jeff Taylor: T-SQL Tuesday #201 Invitation -- Temp Tables, Friend or Foe?
T-SQL Tuesday -- the monthly blog party, started by Adam Machanic
Simple Talk: SQL Server Temp Tables -- Types, Syntax and Best Practices
sqlfingers: DOP Feedback, Part 2 -- Even Microsoft's Demo Didn't Get Me There

Monday, August 3, 2026

DOP Feedback, Part 2 — Even Microsoft’s Demo Didn't Get Me There

In my DOP Feedback post last month, I shared something very frustrating. I had all the documented prerequisites for DOP Feedback in place. Query Store in READ_WRITE mode, DOP_FEEDBACK enabled at the database scope, Compatibility Level 160, MAXDOP set to 14, and a repeatable workload generating real parallelism waits -- but the DOP feedback never fired. No dop_feedback_eligible_query event. No entries in sys.query_store_plan_feedback. Nothing.

I said I'd follow with a sequel when I found a workload that does become eligible, and then I'd compare it against the one that did not. Well. This is that sequel, but I still don't have it. Today is honestly my fourth full-day of testing without triggering any DOP Feedback.

Rather than sh*t canning the whole thing, I thought I'd put out here what I've learned about that doesn't trigger DOP Feedback.

What I Did Right, and What Wasn't The Problem

To clarify right up front, it wasn't my configuration. ALL documented prereqs were satisfied.

The first time around, I believed the problem was my workload. I built a single query, verified it was going parallel at DOP 14, and hammered it repeatedly on an otherwise idle server. THAT was my error, and that is why the feedback loop never engaged.

The DOP Feedback feature isn't meant to flag a single query on an idle server. It is looking for concurrency issues, thread synchronization overhead, and resource contention within a repeating, multi-user workload.

It's looking for queries that burn through server worker threads and act as resource hogs when the database is legitimately under pressure.

Or, as Erik Darling says, "for parallelism, it's less about individual query performance, and more about overall server/workload performance."

First Attempt: My Code

I had created a stored procedure that forced a parallel scan with an ORDER BY on a non-indexed column. The query went parallel with DOP 14 and showed measurable waits:

Wait Type Increase During Test
CXPACKET +27,678 ms
CXCONSUMER +23,424 ms
CXSYNC_PORT +1,130 ms
CXSYNC_CONSUMER +62 ms

Thought I had something usable, so I ran the workload using ostress with multiple connections:

ostress completes and then I checked sys.query_store_plan_feedback:

SELECT
    qspf.plan_id,
    qspf.feature_desc,
    qspf.state_desc,
    qspf.feedback_data,
    qspf.last_updated_time
FROM sys.query_store_plan_feedback qspf
WHERE qspf.plan_id IN (553, 558, 527, 525)
ORDER BY qspf.last_updated_time DESC;

Look at that. CE Feedback evaluated the query but made no recommendation. Memory Grant Feedback evaluated it and decided the feedback was warranted. But DOP Feedback (feature_id = 3) is completely absent. The query was parallel, it was repeating, and other IQP features were engaging -- but DOP Feedback never even looked my way.

Even with heavy, concurrent activity, I still can't trigger the DOP Feedback. I've read the docs again and again, still not finding the fine print that I am missing. So I decided to change my approach.

Second Attempt: The Official Microsoft Demo Query

Maybe my query was the problem. I really don't like copying someone else's code, but after four full days of this, I ran out of reasons not to. I went to Microsoft's demo that's documented to work, and I created the stored procedure from the BobSQL repository:

CREATE OR ALTER PROCEDURE [Warehouse].[GetStockItemsbySupplier]  @SupplierID int
AS
BEGIN
SELECT StockItemID, SupplierID, StockItemName, TaxRate, LeadTimeDays
FROM Warehouse.StockItems s
WHERE SupplierID = @SupplierID
ORDER BY StockItemName;
END;
GO

I populated the data using a modified version of their populatedata.sql script only because the original version ate up too much of my disk -- but I checked when complete, verified the skew and confirmed the DOP at 14. Then I ran the workload exactly as the demo specifies, using workload_index_scan_users.cmd from the command prompt.

And I got the same result:

SELECT 
    qspf.plan_id,
    qspf.feature_desc,
    qspf.state_desc,
    qspf.last_updated_time
FROM sys.query_store_plan_feedback qspf
ORDER BY qspf.last_updated_time DESC;

Again, no DOP Feedback. Memory Grant Feedback was working. The query was in Query Store and was parallel. But DOP Feedback never evaluated it.

Digging Deeper: The BobSQL Demo Steps

Still not ready to scrap it, I went through the BobSQL demo step-by-step (again). The readme lists 15 steps. Here's the full accounting:

Step BobSQL Demo What I Actually Did
1 configmaxdop.sql (MAXDOP = 0) ✓ MAXDOP = 0
2 Copy WWI backup ✓ Did
3 Edit restorewwi.sql paths ✓ Did
4 Execute restorewwi.sql ✓ Did
5 populatedata.sql (full run) ✗ Ran modified version - 3.5M rows, not full ~6.5GB
6 Rebuild indexes ✓ Did
7 dopfeedback.sql ✓ Did
8 proc.sql ✓ Did
9 dopxe.sql ✓ Did
10 workload_index_scan_users.cmd ✓ Ran exactly as documented
11 Monitor XE session ✓ Checked ring buffer
12 dop_query_stats.sql ✓ Did
13 Top Resource Consuming Queries report ✓ Did
14 check_query_feedback.sql ✓ Did
15 Top Resource Consuming Queries report (Avg) ✓ Did

The BobSQL demo is documented to work in Microsoft's published test environment. I reproduced every step except the full data load, which simply exceeded the free disk space on my laptop. Everything else matched the published configuration and workload, yet DOP Feedback still never engaged in my environment.

What I Learned About DOP Feedback's Eligibility

Here's what I now understand based on my testing:

1. The documented prerequisites are necessary but not sufficient.
You can have everything Microsoft says you need, and still never trigger the feature. My testing proved that with both my own query and the official demo query.

2. Other IQP features engaging doesn't mean DOP Feedback will.
Memory Grant Feedback worked consistently on both queries. CE Feedback evaluated one of them. But DOP Feedback never engaged. Presumably, the eligibility criteria are independent and more restrictive.

3. The exact eligibility criteria remain undocumented.
Microsoft describes how the feature works but doesn't publish the internal thresholds that determine when a query crosses the eligibility line. This is consistent with other Intelligent Query Processing features like Memory Grant Feedback, where the internal tuning logic is also opaque.

4. The 'proven' demo requires a specific environment.
The BobSQL demo works in their documented test environment (8 CPUs, 24GB RAM, dedicated VM, fast NVMe storage). My environment is a laptop with 14 CPUs, 16GB RAM (12GB allocated to SQL Server), and NVMe storage. Still robust, but clearly different enough that DOP Feedback was never engaged. Replicating the demo in a different environment may not produce the same results.

The Bottom Line

DOP Feedback has been proven elsewhere, but it's more selective than I expected, and I could not get it to talk to me. Four full rounds of testing. All the documented prerequisites were in place, and I tested with my own query and the official Microsoft demo query. The other IQP features were engaging, but DOP Feedback never evaluated either query.

Not quite the sequel I had planned on, but all of this testing has changed my understanding of DOP Feedback considerably. I no longer think of it as a feature that reacts to 'big parallel queries'. Whatever Microsoft's internal eligibility model is, it's considerably more selective than the public documentation suggests. I still haven't crossed that line -- but I certainly eliminated several assumptions that I started this one with.

If you've successfully triggered DOP Feedback in your environment, I'd love to hear about it. Leave a comment and share what worked for you.

More to Read

sqlfingers — DOP Feedback Part 1
Lee Brownhill: Learning SQL Server 2025 - DOP Feedback
SQLYARD: MAXDOP and DOP Feedback in SQL Server 2022 - The Complete Guide
Microsoft BobSQL Demo: DOP Feedback
Pinal Dave: SQL Server 2022 - DOP Feedback

Wednesday, July 22, 2026

SSRS Is Dead. Here's The Move to PBIRS.

A few months back I wrote SSIS Is Not Dead. Yet., opening with the line 'SSRS is gone'. Today it's time to give SSRS the send-off it deserves. SQL Server 2025 shipped without SSRS and Microsoft has named Power BI Report Server (PBIRS) as the on-premises replacement. No new SSRS version is coming. Per Microsoft's Reporting Services Consolidation FAQ, SSRS 2022 is the end of the line.

No panic. We've got time. SSRS 2022 is supported until January 11, 2033. That makes this a planning problem, not a fire drill. But understand that 'supported' in this context means security patches, and nothing more. All future investment is in PBIRS. So the first question is not how to move. It's whether you need to move at all.

Do You Even Need to Move Yet?

Honest answer: Not today. Your reporting catalog can sit on a newer database engine while SSRS 2022 keeps serving reports. Use this to place yourself:

Your Situation Your Move
Paginated-only, SSRS 2022 Stay put. Supported to 1/11/2033.
Upgrading engine to SQL 2025 Plan the PBIRS migration as its own track.
Want .pbix reports on-prem Migrate. Only PBIRS hosts both RDL and PBIX.
SharePoint-integrated mode Move to Native mode first. No direct path.
SSRS on Express edition Express loses reporting rights in 2025. Time to license up.

Important to know that 'no new SSRS' is not the same as 'SSRS stops working'. If you run pixel-perfect paginated reports and nothing else, you have seven years of runway. Use that runway wisely.

Licensing: The Part That Changed in Your Favor

For SQL Server 2022 and earlier versions, PBIRS on-prem required Enterprise edition plus Software Assurance, or a standalone Power BI Premium purchase. That wall disappeared with SQL Server 2025. Per the consolidation FAQ:

Your License PBIRS Access
SQL 2025, any paid edition Same 2025 key installs PBIRS. Done.
SQL 2022 and earlier Enterprise + SA only; MSFT provides the PBIRS key.
Just kicking tires Free Developer or Evaluation edition.

If you are Standard edition without SA, this is the first time PBIRS has been realistically on your menu. That alone changes the map for a lot of shops.

The Migration Itself

There is no in-place upgrade. You do not 'upgrade' SSRS to PBIRS. You install PBIRS and walk your report catalog over to it. Microsoft's official migration guide covers the full procedure, and SQLServerCentral published a great end-to-end walkthrough going from a SQL 2016 SSRS source to a SQL 2025 PBIRS destination.

Check your PBIRS build before you start. A SSRS 2022 catalog requires the May 2025 release of PBIRS, or newer. Anything older will not accept it, and you will find that out at Step 5, not Step 1.

The demo below uses RPTSQL01 for the old SSRS box, and RPTSQL02 for the new PBIRS box.

Step 1: Back up the encryption key. FIRST.

The key protects your stored credentials and connection strings. Lose it, and every data source on the new server greets you with a credential prompt. Use Report Server Configuration Manager (Encryption Keys > Backup), or the command line:

-- Run on RPTSQL01, adjust paths for your environment
rskeymgmt -e -f "D:\Backup\rs_key.snk" -p "YourStrongPassword"

Step 2: Back up the ReportServer databases and config files.

-- Run on RPTSQL01
BACKUP DATABASE [ReportServer]
  TO DISK = 'D:\Backup\ReportServer.bak'
  WITH INIT, COMPRESSION;

BACKUP DATABASE [ReportServerTempDB]
  TO DISK = 'D:\Backup\ReportServerTempDB.bak'
  WITH INIT, COMPRESSION;

Get copies of rsreportserver.config, rssrvpolicy.config, and web.config from the SSRS install path. You will want them for reference of any custom settings.

Step 3: Install PBIRS.

Just like with SSRS, you can install PBIRS on its own dedicated host with the ReportServer databases on a separate SQL Server instance, OR they can be combined on a single host. Straightforward install. See Install Power BI Report Server. One difference from SSRS is that the installer does not let you name the PBIRS instance. It is always PBIRS. Any scripts, monitoring, firewall rules, or documentation that reference the old ReportServer instance should be reviewed before cutover. Also good to know that migrating SSRS-to-PBIRS on the same hardware is supported.

Step 4: Restore the catalog. Same database name.

-- Run on the database instance for RPTSQL02
RESTORE DATABASE [ReportServer]
  FROM DISK = 'D:\Backup\ReportServer.bak'
  WITH MOVE 'ReportServer'     TO 'E:\Data\ReportServer.mdf',
       MOVE 'ReportServer_log' TO 'F:\Logs\ReportServer.ldf',
       RECOVERY;

The name must stay ReportServer. Do not get clever here.

Whatever your catalog database was named on the source -- ReportServer for most of us -- it needs to use that exact name on the destination. This is not the moment to standardize naming conventions. Rename it during the restore and PBIRS will not recognize its own catalog.

Step 5: Connect and restore the key.

Open your new Report Server Configuration Manager, connect the PBIRS instance to the restored catalog, then restore the encryption key from Step 1. Your reports, folders, security assignments, and subscriptions come along with the catalog.

Step 6: Validate, then clean up the old SSRS instance.

I recommend running the old and new in parallel until the new server has survived at least one complete business cycle, or until you've validated every subscription frequency used in your environment. Then you can decom the old SSRS box, RPTSQL01.

The Gotchas Nobody Mentions Until Go-Live

Subscriptions are SQL Agent jobs

The ReportServer database restore brings subscription definitions, not the Agent jobs that fire them. You need to verify that the corresponding SQL Agent jobs have been recreated and are executing successfully. There is a known pattern on the Fabric community forums: migration completes, all jobs show green, and no report emails ever arrive. Check ReportServerService_* logs under the PBIRS LogFiles directory, and always test one net-new subscription against your migrated ones.

The Migration Wizard has blind spots

The SSRS Reports Migration Wizard is a handy alternative to the catalog-restore method, and it does support PBIRS as both source and target. But you must know what it leaves behind. It moves your RDLs, shared datasets, data sources, and standard subscriptions. It does not move data-driven subscriptions, execution log history, or .pbix files, and it only speaks Native mode. If any of those matter to you, the catalog restore remains your best option.

SQL 2025 encryption defaults can silently break data sources

SQL Server 2025 enforces TDS 8.0 and stricter encryption out of the box. Report data sources and linked servers riding on older drivers can fail after the engine upgrade, and it will not show up in your database-level testing. Validate every shared data source against the new engine, not just the reports.

Restore custom assemblies/extensions

Custom assemblies and extensions are not stored in the ReportServer catalog. If you've customized SSRS beyond stock installations, inventory those components before shutting the old server down.

SharePoint-integrated mode is a dead end.

MSFT deprecated it, and no migration path targets it. If you are one of the few still running it, your first project is SharePoint-to-Native, and PBIRS is your second.

The Bottom Line

If your engine upgrade to SQL 2025 includes reporting, you've got two projects ahead of you, not one. The engine track and the reporting track have separate acceptance criteria — subscription schedules, encryption keys, stored credentials, custom extensions and URL reservations. Teams that treat them as one project may very well find their reporting tier is broken post-go-live, in ways their testing never could have surfaced.

SSRS has served us since 2004 and has earned a graceful exit. Let's give it one. Back up that encryption key, run parallel until a full subscription cycle passes clean, and do not let January 2033 arrive with this still on the to-do list. You've got plenty of time. For now.

More to Read

MSFT — Reporting Services Consolidation FAQ
MSFT — Migrate a report server installation
SQLServerCentral — Migrate SSRS Reports to PBIRS in SQL Server 2025
sqlfingers — SSIS Is Not Dead. Yet.

Tuesday, July 21, 2026

How Hard Is It to Trigger SQL Server 2025's DOP Feedback?

When Microsoft introduced Degree of Parallelism (DOP) Feedback for SQL Server, I immediately wanted to see it in action. The idea is compelling: if a repeating query uses more parallel workers than it really needs, SQL Server learns from previous executions and quietly chooses a better DOP the next time.

At least, that's how I understood it.

I decided to test it out in the lab.

The Lab

I started with SQL Server 2025 RTM and enabled the documented prerequisites for DOP Feedback.

  • Query Store in READ_WRITE mode
  • DOP Feedback enabled
  • Compatibility Level 160

From there, I configured the environment to encourage parallel plans.

  • MAXDOP set to 14
  • Cost Threshold for Parallelism reduced to 50
  • Batch Mode on Rowstore disabled during part of the testing to force row-mode execution

I built a repeatable workload, verified an actual DOP of 14, confirmed row-mode execution, and watched Query Store capture every execution.

Everything appeared to satisfy the documented requirements, so I expected SQL Server to at least consider the workload.

Then I Started Measuring

The execution plan was fully parallel.

The workload executed repeatedly.

Query Store captured every execution.

The query also generated measurable parallelism waits:

Wait Type Increase During Test
CXPACKET +27,678 ms
CXCONSUMER +23,424 ms
CXSYNC_PORT +1,130 ms
CXSYNC_CONSUMER +62 ms

This wasn't a tiny query. It was clearly exercising the parallel execution engine.

So I expected the workload to become a candidate for DOP Feedback.

It Never Happened.

sys.query_store_plan_feedback remained empty again and again.

I decided to create an Extended Events session to capture the DOP Feedback lifecycle, but the event I expected to see most, dop_feedback_eligible_query, never fired.

Then I thought compatibility level might be the missing piece, so I repeated the entire test under Compatibility Level 170.

No change.

No persisted feedback. No eligibility event. No evidence that SQL Server ever considered my workload a candidate for DOP Feedback. Ever.

At this point, the absence of evidence had become evidence itself.

What changed?

My understanding of the feature. I began this test assuming that a sufficiently expensive parallel query with measurable CX waits would become eligible for DOP Feedback. But nothing in my testing supported that assumption.

Instead, the evidence suggests something more interesting.

It tells me that DOP Feedback isn't just looking for every expensive parallel query. There are clearly additional eligibility criteria that determine whether SQL Server even considers a query for review. Those criteria aren't fully documented, and my workload never satisfied them.

Microsoft documents the feature and its prerequisites. Those internal decision points, however, remain intentionally undocumented.

My workload simply never crossed that line.

At this point I stopped trying to invent new workloads. If SQL Server wasn't even raising the eligibility event, making the query bigger or more expensive wasn't going to teach me anything. The more interesting question now is why the engine ignored the workload in the first place.

Bottom Line

I set out to demonstrate SQL Server automatically tuning a query. Instead, I found something I wasn't expecting.

After verifying Query Store, DOP Feedback, Compatibility Levels 160 and 170, row-mode execution, repeated executions, measurable CX waits, and a fully parallel execution plan -- SQL Server never considered the workload eligible for DOP Feedback.

That doesn't mean DOP Feedback doesn't work. It means SQL Server is far more selective than I expected.

One of the easiest mistakes to make with adaptive features is assuming they'll engage simply because all the prerequisites are enabled. My tests have reminded me that's only the beginning. The harder part is understanding what makes a workload eligible in the first place.

My next step is to find a workload that does become eligible and compare it against this one. When I find it, I'll write the sequel.

More to Read

Microsoft Learn: Degree of parallelism (DOP) feedback
MSFT Tech Community: Smarter Parallelism -- DOP feedback in SQL Server 2025
Microsoft Learn: sys.query_store_plan_feedback
Erik Darling: What’s The Point Of DOP Feedback In SQL Server 2022?

Sunday, July 19, 2026

SSMS 22.7 Schema Compare — Before You Click Apply

For twenty years, comparing two database schemas meant leaving SSMS. You opened Visual Studio and SSDT, or you paid for a third-party tool, or you did what most of us actually did -- scripted both sides out and eyeballed the diff. Schema compare has been one of the most requested features in SSMS for as long as I can remember.

SSMS 22.7 finally includes this feature. Graphical schema compare, native, no add-ins, in preview. You pick a source and a target, it shows you every difference, and it will write the script to make the target match -- or even apply the changes directly, if you let it.

That last part is where I want to spend some time because the compare is the easy half. The Apply button is the piece that can hurt you.

What It Is and What You Need

Schema compare works against any two of a live database, a SQL database project, or a .dacpac file, in either direction. A .dacpac is a data-tier application package -- a single file containing a database's full schema definition, no data, the same format SSDT has used for deployments for years. A SQL database project is the source-control side of the same thing: a folder of CREATE scripts, one per object, that builds into a .dacpac. If neither of those is part of your world, no worries! The database-to-database comparison is the bread and butter here, and it's likely all most of us will use.

Required version is SSMS 22.7.0 or later. The feature ships with the base install, so comparing live databases or .dacpac files needs nothing extra. Only SQL database projects require the Database DevOps workload, which is one of SSMS 22's optional components. To install it, launch the Visual Studio Installer, choose Modify on your SSMS 22 installation, check the Database DevOps workload, and let it update. It is still a preview feature, so expect some rough edges and remember that it's likely to change before GA.

There are three ways to open it, and where you start determines what gets filled in for you:

Object Explorer

Right-click a database, Tasks > Schema Compare (Preview). That database becomes your source.

Solution Explorer

Right-click a SQL database project, Schema Compare (Preview). This is the one that requires the Database DevOps workload.

Tools menu

Tools > Schema Compare. Opens empty, and you pick both source and target yourself.

However you get there, the workflow is the same. Run the comparison and the differences come back in a grid, grouped by action -- adds, changes, and deletes. Selecting an object shows its side-by-side difference in the lower pane, and from there you decide what happens next. You can check or uncheck individual changes, then either Generate Script, which opens the change script in a new query window for your review, or Apply, which runs the sync against the target right then and there. And, if it's a comparison you'll run regularly, like a weekly sync between production and dev, you can save the whole configuration, source, target, options and all, as an .scmp file. Then you reopen and reuse it next week instead of having to set everything up all over again.

If you've spent any time with schema compare in Visual Studio database projects, all of this will feel familiar. It's the same workflow, finally living where DBAs actually work.

The Sandbox

To test it, I need two databases that disagree. The setup below builds a 'dev' and a 'prod' copy of the same schema, then plants five specific differences between them. Each one is a kind of drift I've met in the wild, and each one should land in the compare results differently.

This is schema only, no data load. Schema compare doesn't read your rows, so this post doesn't need any.

USE master;
CREATE DATABASE CompareDev;
GO
CREATE DATABASE CompareProd;
GO

-- the matching objects in both databases
USE CompareDev;
GO
CREATE TABLE dbo.tblCustomers
(
    CustomerID INT IDENTITY(1,1) PRIMARY KEY,
    CustomerName VARCHAR(100) NOT NULL,
    Region VARCHAR(20) NOT NULL
);
CREATE TABLE dbo.tblOrders
(
    OrderID INT IDENTITY(1,1) PRIMARY KEY,
    CustomerID INT NOT NULL,
    OrderDate DATETIME NOT NULL,
    OrderTotal DECIMAL(10,2) NOT NULL
);
GO
CREATE PROCEDURE dbo.usp_GetOrdersByRegion
    @Region VARCHAR(20)
AS
BEGIN
    SET NOCOUNT ON;
    SELECT o.OrderID, o.OrderDate, o.OrderTotal
    FROM dbo.tblOrders o JOIN dbo.tblCustomers c 
      ON c.CustomerID = o.CustomerID
    WHERE c.Region = @Region;
END;
GO

USE CompareProd;
GO
CREATE TABLE dbo.tblCustomers
(
    CustomerID INT IDENTITY(1,1) PRIMARY KEY,
    CustomerName VARCHAR(100) NOT NULL,
    Region VARCHAR(20) NOT NULL
);
CREATE TABLE dbo.tblOrders
(
    OrderID INT IDENTITY(1,1) PRIMARY KEY,
    CustomerID INT NOT NULL,
    OrderDate DATETIME NOT NULL,
    OrderTotal DECIMAL(10,2) NOT NULL
);
GO
CREATE PROCEDURE dbo.usp_GetOrdersByRegion
    @Region VARCHAR(20)
AS
BEGIN
    SET NOCOUNT ON;
    SELECT o.OrderID, o.OrderDate, o.OrderTotal
    FROM dbo.tblOrders o JOIN dbo.tblCustomers c 
      ON c.CustomerID = o.CustomerID
    WHERE c.Region = @Region;
END;
GO


-- now the drift. five intentional differences
-- 1. new table in dev only (an add)
USE CompareDev;
GO
CREATE TABLE dbo.tblLoyaltyTier
(
    TierID INT IDENTITY(1,1) PRIMARY KEY,
    TierName VARCHAR(30) NOT NULL
);
GO

-- 2. new column in dev only (a change)
ALTER TABLE dbo.tblCustomers ADD Email VARCHAR(255) NULL;
GO

-- 3. proc modified in dev only (a change)
ALTER PROCEDURE dbo.usp_GetOrdersByRegion
    @Region VARCHAR(20)
AS
BEGIN
    SET NOCOUNT ON;
    SELECT o.OrderID, o.OrderDate, o.OrderTotal, c.CustomerName
    FROM dbo.tblOrders o JOIN dbo.tblCustomers c 
      ON c.CustomerID = o.CustomerID
    WHERE c.Region = @Region
    ORDER BY o.OrderDate DESC;
END;
GO

-- 4. old table still in prod only (a delete, if you let it)
USE CompareProd;
GO
CREATE TABLE dbo.tblLegacyImport
(
    ImportID INT IDENTITY(1,1) PRIMARY KEY,
    RawData VARCHAR(500) NULL
);
GO

-- 5. the emergency index. added in prod during a fire,
--    never backported to dev. we all have one of these.
CREATE NONCLUSTERED INDEX idx_tblOrders_OrderDate
    ON dbo.tblOrders (OrderDate)
    INCLUDE (OrderTotal);
GO

Five differences, and they are not all the same kind of dangerous:

# Drift Compare Should Say Risk on Sync
1 tblLoyaltyTier in dev only Add None. New table.
2 Email column in dev only Change Low. Nullable add.
3 Proc changed in dev Change Low. ALTER PROC.
4 tblLegacyImport in prod only Delete High. DROP TABLE.
5 Index in prod only Delete High. Drops a prod index.

Numbers 4 and 5 are the real test here. Syncing dev to prod means making prod look exactly like dev -- and anything that exists only in production becomes targeted for removal. In this sandbox that's a leftover table and an index. Number 5 is one we'll all recognize. The index that went into production during an emergency, but never made it back to dev or into the next formal deployment. It's an old story, and a schema compare tool could help you avoid that the next time around.

Running the Compare

Getting started is quick. In Object Explorer, right-click CompareDev and select Tasks > Schema Compare (Preview). The Schema Compare window opens with CompareDev already set as the source, which leaves the target. Click the ellipsis next to the Target box, choose a database as the target type, connect to your instance, and pick CompareProd. With both sides set, click Compare and the tool goes to work.

Notice the direction I chose. Dev as source and prod as target is the standard deployment direction, but also a dangerous one. Everything in dev that prod doesn't have becomes an add, and everything in prod that dev doesn't have becomes a delete.

The Script It Writes

The next part is what matters most to me. I clicked Generate Script with everything included and nothing excluded. You know, the scenario that happens when a hurried DBA runs the compare.

The generated script runs about a hundred lines, and most of it is standard deployment scaffolding full of SET options with PRINT statements between steps. Two very interesting findings: First, it's a SQLCMD script, full of :setvar and :on error exit directives, so it will not run in a normal query window. You will need to enable SQLCMD Mode (Query menu > SQLCMD Mode) or it errors out immediately -- the script even checks for this itself. And secondly, there is no transaction wrapping. The :on error exit directive stops the script when a step fails, but anything already applied stays applied. Keep that in mind before running it against anything that matters.

Now the part I built this sandbox to see. Here's what it generated for the prod-only table:

Credit where due. Before dropping tblLegacyImport, the script checks whether the table contains data, and if it does, the deployment halts rather than destroy rows. My sandbox table is empty, so the drop proceeds, but the point is that a populated table stops the whole script. That's a real guardrail.

And here is what it generated for the prod-only index:

No check, no warning, no halt. A PRINT statement and a DROP. The table gets a data-loss guard because dropping data is understood to be dangerous, but the emergency index -- the one that went into production purposely -- is removed without a second thought. The script announces it politely on its way out the door. If you weren't reading closely, number 5 just happened to you.

Apply vs Generate Script

There are two buttons at the top of the Schema Compare window that I need to emphasize. Generate Script opens the changes in a query window and hands you the wheel. So you get to read it and run it, on your terms. Apply skips all of that and runs the sync against the target directly. I wondered, do you get any chance to verify? And how much time between clicking Apply and the changes landing?

Exactly the confirmation I hoped to see. After clicking Yes, the synchronization begins immediately and finishes with one of these:

I even ran it once more to be sure the system detects the changes, and it found 0 differences.

My Verdict

I have to say that I like it. Even though my test was minimal, it did exactly what I asked it to, and even had guardrails for me, in case I wasn't fully paying attention to my ask. Schema Compare is a feature I will use. Generate Script will probably remain my default button. Apply has earned my respect, but not my trust. Yet.

One last note: Microsoft is collecting feedback on this feature right now, before it goes GA. This means use it. Beat it up real good and if the options don't protect the things you think need protected, this is the time to say so. Complain early, complain often. Help them make this into something we can comfortably rely upon.

Cleanup, because we always clean up:

USE master;
DROP DATABASE CompareDev;
DROP DATABASE CompareProd;

More to Read

Microsoft Learn: Schema Compare (Preview) in SQL Server Management Studio
Microsoft: Announcing the Release of SSMS 22.7.0 -- and many previews
sqlfingers: SSMS 22: What's Different, What's Worth It, and What May Bite You

Friday, July 17, 2026

The SQL Server DBA's Guide to AI Tools in 2026

Two years ago, 'AI for SQL Server' mostly meant pasting your T-SQL into a web browser, wrestling with prompts until it stopped hallucinating and screaming at ChatGPT. Today, the market has matured and split into three distinct battlefields: writing T-SQL faster, agentic DBA operations (plan tuning, audits, diagnostics), and building text-to-SQL solutions. The right tool depends on what you're trying to accomplish, because AI for SQL Server has become a collection of specialized tools rather than one-size-fits-all assistants.

Before comparing tools, it's worth acknowledging that much of the published 'AI tool comparison' content is written by vendors ranking themselves first, and several widely quoted benchmarks weren't even run against SQL Server. Rather than marketing claims, this post leans primarily on practitioner experience from SQL Server DBAs, in addition to the vendor documentation.

The Good

GitHub Copilot completions in SSMS -- the easy win

Copilot is now a standard SSMS 22 workload: sign in with a GitHub account and you get inline T-SQL completions (my fave), chat, and database-aware answers grounded in your active connection. The free tier (about 50 requests per month) costs nothing to try, and Pro is $10/month. As of SSMS 22.3, you can even store coding-standard instructions as extended properties at the database or object level, and Copilot uses them when generating code, which is a genuinely useful way to make its output match your shop's standards.

Here's the important part: Copilot in SSMS is really two features wearing one name, and they've earned very different reputations. The inline completions are excellent. Brent Ozar recommends enabling them immediately and letting Copilot handle repetitive joins, syntax, and boilerplate. The chat and Agent features are a very different story. See 'The Ugly' below.

Before you standardize on it, keep two things in mind. First, the free tier disappears quickly once you use Copilot for more than code completion. Second, your prompts and database metadata are sent to GitHub-hosted models, which may be a show-stopper if your environment prohibits sending schema or query text outside your organization.

dbForge AI Assistant -- best add-in for pure query development

Devart's AI Assistant plugs into dbForge Studio for SQL Server or into standard SSMS via dbForge SQL Complete. It's great at converting natural language to valid T-SQL, explaining legacy code, troubleshooting syntax errors, and offers optimization suggestions -- all without leaving SSMS or dbForge Studio.

It has genuine schema awareness, deep IDE integration, and a mature surrounding ecosystem of formatting, refactoring, and schema-comparison tools.

One Catch: It is tied to a commercial tool suite. The true cost is the broader dbForge licensing model, meaning you cannot buy the AI engine as a standalone, cheap utility.

SsmsAgentic -- agentic DBA work inside SSMS

A third-party VSIX that adds a Claude-powered agent pane to SSMS 22. Unlike completion tools, it executes the diagnostic work itself. It performs investigative DBA work by reading metadata, DMVs, execution plans and permissions, and then proposes actions for approval. It reuses your existing SSMS connection (including Entra ID) and your existing Claude plan, so there's no second AI bill. One-time license ($49–$199) after a 15-day trial.

It's the only SSMS-native option today doing actual investigative DBA work rather than suggesting code for you to run, and per-statement approval is the right security posture for agentic database access.

One Catch: Because it is third-party and not MSFT-backed, it requires the Claude CLI installed locally, SSMS v22+, and it is Windows only. Full disclosure: product reviews are still very thin out there, so these details come from the vendor's own site.

Claude Code + sqlcmd (or an MCP) -- the practitioner's agentic path

This one earned its spot from practitioner writing, not vendor marketing. Brent Ozar documented his own workflow using Claude Code against SQL Server and Azure SQL DB: at its simplest, Claude Code calls sqlcmd to run queries and read results, and as you get more advanced, you can wire up an MCP for richer database access. His advice for getting started safely: practice on an open-source repo you already use, like the FRK, Ola Hallengren's maintenance scripts, or DBAtools -- not your production estate.

You pick the model, you see the whole prompt, and nothing sits between you and the LLM rewriting your question. It's the same 'agentic' capability the shrink-wrapped tools sell, minus the black box.

One Catch: command-line workflow, more assembly required, and the same governance question as every agentic tool -- what you let it touch is on you.

Microsoft SQL MCP Server -- for building AI applications

MSFT's open-source MCP server (shipped via Data API Builder 1.7+) exposes a SQL database to any MCP-aware client -- Claude Desktop, VS Code, Cursor, or your own agent -- through a fixed set of typed CRUD tools with schema-level RBAC. This is the right shape when you're building an app or agent (ie., a support copilot that looks up orders) rather than assisting a human at a keyboard. A recent SQLServerCentral write-up found the setup much easier than expected, quirks and all.

It's free, open-source, runs on-prem, and it's the only path on this list that works from Mac and/or Linux.

One Catch: it's plumbing, not a product. No UI, no NL2SQL, DML only -- no DDL, no DMV access, no plan analysis. Setup means JSON config and usually hosting a container. And read 'The Ugly' before you hand it your data.

The Bad

To be clear, 'bad' here doesn't mean bad software. It means tools people are reaching for that are a poor fit for SQL Server work specifically.

Raw ChatGPT / generic chat with no database context

Still the most common choice, and still the most error-prone for production work. With no awareness of your schema, generic LLMs invent plausible table and column names, and they chronically cross dialects -- emitting LIMIT instead of TOP, or PostgreSQL :: casting instead of CAST(), even when told to target SQL Server. Fine for learning concepts but risky for anything you'll execute against a live database without careful review. Redgate's Simple Talk lands in the same place: you can trust AI with database issues, but verify every response it gives you. It's worth noting that the practitioner objection isn't to the LLMs themselves -- Brent Ozar's own preferred workflow sends well-built prompts to the LLM of your choice. The failure is with the missing context, not the model.

Amazon Q

Genuinely useful if your data lives in Redshift or Athena -- but close to useless for native SQL Server. No support for databases outside AWS, setup requires IAM and CLI configuration, and even within AWS its quality varies by service. If your estate is SQL Server on-prem or on Azure, skip it entirely.

Zero-setup 'paste your schema' web portals

Lightweight browser apps where you paste DDL and ask questions. The convenience is real, but dialect support skews heavily toward PostgreSQL and MySQL, accuracy drops sharply on complex queries, and manually pasting hundreds of lines of SQL Server schema into an unsecured browser text field gets old fast -- and should make your security team twitch. With no execution capability, you're copy-pasting in both directions, and the AI is blind to the things that actually matter: bottlenecks, waits, and lock escalation.

Generic text-to-SQL tools used for DBA work

Standalone tools like AI2SQL are legitimately good at their actual job -- letting analysts and PMs generate reporting queries from plain English against a connected schema. But they're query generators, not DBA tools. No SSMS integration, no DMV access, no execution-plan work, no migration or security-audit capability. Using them for SQL Server administration is a category error. Their published accuracy benchmarks were run against PostgreSQL, so the numbers don't transfer to T-SQL, which brings us to the ugly part.

The Ugly

The ugly here isn't referencing actual tools. It's the pieces inside the tools that are problematic and you need to know about.

SSMS Copilot rewrites your prompts -- and you can't see how

Brent Ozar's testing found that SSMS Copilot's advice quality lags well behind asking the same LLMs directly, and the culprit is the prompt Copilot wraps around your question. By 2026 he'd concluded that between SSMS interfering with his prompts and Copilot's shift toward usage-based pricing, the chat simply isn't worth using directly -- his workaround is building the prompt himself in T-SQL (the @AI parameter in sp_BlitzCache and sp_BlitzIndex) and sending it to whichever LLM he chooses. The kicker, courtesy of his readers: the chat logs -- including the hidden system prompt MSFT prepends to your questions -- sit in plain text under %localappdata%\SSMSCopilot. The tool most shops will standardize on is the one where you have the least visibility into what's actually being asked.

The benchmark shell game

Most 'AI for SQL' comparison content is written by vendors ranking themselves first, and the accuracy numbers that get repeated across these roundups trace back to benchmarks run against PostgreSQL schemas. PostgreSQL accuracy tells you nothing about whether a tool writes correct T-SQL, handles DMVs, or can read an execution plan. If a vendor quotes a percentage, you need to ask what engine it was measured on.

Your data goes along for the ride

Every one of these tools -- Copilot, agentic extensions, MCP pipelines -- ships your prompts, metadata, and query results to somebody's hosted model. The SQLServerCentral author who wired Claude Desktop to his ERP database said it very clearly: whoever uses the MCP has access to that data, and the data is sent to the provider for processing. He also had to disable Row-Level Security to get his demo working -- which is exactly the kind of shortcut that migrates quietly from demo to production. The permission model is on you. Dedicated SQL logins with minimal rights, no DELETE or DROP grants for agents, and a hard look at what your compliance posture allows to leave the building.

How to Choose

The pragmatic reality for most database teams is that you won't rely on a single tool. The common setup is a fast completion tool for raw typing speed, paired with one agentic path for the investigative work that used to consume your entire afternoon.

Your situation Use this
Write T-SQL all day in SSMS GitHub Copilot completions
Want AI to do the investigation
(slow-query triage, index review, audits)
SsmsAgentic, or Claude Code + sqlcmd/MCP
Want full control of the prompt Claude Code + sqlcmd, or @AI in sp_Blitz tools
dbForge shop dbForge AI Assistant
Building an app or agent SQL MCP Server
On Mac/Linux SQL MCP Server + Claude Desktop or VS Code
Non-technical, just need a query Schema-connected text-to-SQL tool

One Last Thought

After comparing today's AI tools for SQL Server, one thing became clear: there really isn't a single 'best' choice. Nearly every tool on this list can write T-SQL. The real differences are how well they understand SQL Server, how transparent they are about what they're doing, and how much control they leave in your hands.

If I were building a toolkit today, I'd install GitHub Copilot for everyday coding, use Claude Code for investigative work, choose dbForge AI if I already lived in the dbForge ecosystem, and reach for SQL MCP only when building AI applications. Together, those tools cover nearly everything a SQL Server DBA is likely to need today.

The best AI tool for SQL Server isn't the one that writes the most code. It's the one that helps you make better decisions while keeping you firmly in control.

More to Read

Brent Ozar - SSMS Copilot is Messing With Your AI Prompts
Brent Ozar - Using Claude Code with SQL Server and Azure SQL DB
SQLServerCentral - From SQL Server On-Premises to Claude Desktop: A Full MCP Pipeline
SsmsAgentic - AI for SQL Server
Devart - dbForge AI Assistant for SQL Server
AI2SQL - Best AI SQL Tools 2026
Demis Hassabis - A Framework for Frontier AI and the Dawning of a New Age