03/10/2026

SQL Server Performance Tuning Excersises - 4

This is the 4th part of the blog series where we discuss about SQL server performance tuning exercises generated by Claude.

This time I asked for expert level challenge to see how it going to be.

Scenario

This time we have a Internal app.

"Community Activity Digest" screen in a .NET Core / React app. Users pick a date range and page through questions ranked by an engagement score. Standard grid, 50 rows a page.

Issue: The Digest screen "randomly freezes." Not always — mornings are mostly fine, but between roughly 10am and 4pm when the ops team is all in the tool, users report the grid spinner sitting there for 40–90 seconds and then, without them doing anything, the data just appears. Refresh immediately after and it's fast. Nobody can reproduce it on demand.

Dev team has already on the issue. They have checked 3 things and ruled them out.

1.The plan in cache is identical between fast and slow runs — they grabbed it both times. Same handle, same plan, same estimated cost. So they've ruled out parameter sniffing.

2. cpu_time on the slow runs is tiny compared to total_elapsed_time — roughly 2 seconds of CPU against 60 seconds elapsed. But investigation showed procedure return exactly 50 rows and API layer reads the reader with not much processing.

3. Logical reads are the same on fast and slow executions

Some more additional details are also provided, mentioning might not relate or not.

  • Page 1 is exactly as slow as page 400.
  • There is a known bug: some posts appear two or three times in the list, and the duplicates push real results onto the next page.

The DBA has already added covering indexes for this query and considers the indexing "done."

Setup

Following setup script change the compatibility level and create the index mentioned above.

USE StackOverflow2010;   -- or StackOverflow2013
GO

/* Production runs on compat 140. Match it — this matters, and I'm not
   going to tell you why yet. */
ALTER DATABASE CURRENT SET COMPATIBILITY_LEVEL = 140;
GO

/* The DBA's "covering" indexes. These are genuinely reasonable indexes
   for this query shape. Heads up: the INCLUDE lists make these fat.
   On StackOverflow2010 expect a few GB and several minutes; on 2013
   expect considerably more of both. Build them when you have time. */
DROP INDEX IF EXISTS IX_Posts_PostTypeId_CreationDate_Includes ON dbo.Posts;
CREATE INDEX IX_Posts_PostTypeId_CreationDate_Includes
    ON dbo.Posts (PostTypeId, CreationDate)
    INCLUDE (OwnerUserId, Score, CommentCount, ViewCount, Title, Tags);
GO

DROP INDEX IF EXISTS IX_Comments_PostId_CreationDate ON dbo.Comments;
CREATE INDEX IX_Comments_PostId_CreationDate
    ON dbo.Comments (PostId, CreationDate)
    INCLUDE (Text, UserId);
GO

Stored procedure behind the page

CREATE OR ALTER PROCEDURE dbo.usp_GetActivityDigestPage
    @StartDate  datetime,
    @EndDate    datetime,
    @PageNumber int,
    @PageSize   int
AS
BEGIN
    SET NOCOUNT ON;

    ;WITH Digest AS
    (
        SELECT
            PostId          = p.Id,
            p.Title,
            p.Tags,
            p.CreationDate,
            p.Score,
            p.ViewCount,
            p.CommentCount,
            CommentText     = c.Text,
            CommentDate     = c.CreationDate,
            AuthorName      = u.DisplayName,
            AuthorLocation  = u.Location,
            AuthorAboutMe   = u.AboutMe,
            EngagementScore = (p.Score * 2) + p.CommentCount,
            RowNum          = ROW_NUMBER() OVER (
                                  ORDER BY (p.Score * 2) + p.CommentCount DESC,
                                           p.Id ASC)
        FROM dbo.Posts AS p
        LEFT JOIN dbo.Comments AS c
               ON c.PostId = p.Id
        JOIN dbo.Users AS u
               ON u.Id = p.OwnerUserId
        WHERE p.PostTypeId = 1
          AND p.CreationDate >= @StartDate
          AND p.CreationDate <  @EndDate
    )
    SELECT PostId, Title, Tags, CreationDate, Score, ViewCount, CommentCount,
           CommentText, CommentDate, AuthorName, AuthorLocation, AuthorAboutMe,
           EngagementScore, RowNum
    FROM Digest
    WHERE RowNum BETWEEN ((@PageNumber - 1) * @PageSize) + 1
                     AND  (@PageNumber * @PageSize)
    ORDER BY RowNum;
END
GO

Typical call to the store procedure

EXEC dbo.usp_GetActivityDigestPage
     @StartDate = '2010-01-01', @EndDate = '2010-07-01',
     @PageNumber = 1, @PageSize = 50;

EXEC dbo.usp_GetActivityDigestPage
     @StartDate = '2010-01-01', @EndDate = '2010-07-01',
     @PageNumber = 400, @PageSize = 50;

Setting up like above and running the query, will not re-produce the intermittent issue. Claude as gone on to explain how to re-produce the intermittent issue.

First it wants us to constraint memory.

SELECT [name], value_in_use FROM sys.configurations
WHERE [name] = 'max server memory (MB)';

EXEC sys.sp_configure 'show advanced options', 1; RECONFIGURE;
EXEC sys.sp_configure 'max server memory (MB)', 4096; RECONFIGURE;

Then open three SSMS windows and use a synchronised start so they all fire at once. Set the time a minute or two ahead:

WAITFOR TIME '14:30:00';
EXEC dbo.usp_GetActivityDigestPage
     @StartDate = '2010-01-01', @EndDate = '2010-07-01',
     @PageNumber = 1, @PageSize = 50;

And in a fourth window, watch what happens while they run. Poll this repeatedly during the window:

SELECT  r.session_id,
        r.status,
        r.wait_type,
        r.wait_time,
        r.cpu_time,
        r.total_elapsed_time,
        r.logical_reads,
        g.request_time,
        g.grant_time,
        g.requested_memory_kb,
        g.granted_memory_kb,
        g.ideal_memory_kb,
        g.required_memory_kb,
        g.queue_id,
        g.wait_time_ms,
        g.dop
FROM sys.dm_exec_requests AS r
LEFT JOIN sys.dm_exec_query_memory_grants AS g
       ON g.session_id = r.session_id
WHERE r.session_id > 50
  AND r.session_id <> @@SPID;

This is what it want me to look into:

Tell me the wait the users are actually experiencing, and explain precisely why CPU time and logical reads are both flat across fast and slow executions while elapsed time varies by 30x — that pairing is the whole diagnostic signature and if your explanation doesn't account for both, it's not the right answer.

Then explain why page 1 and page 400 cost the same. There's a specific structural reason, and it's not "SQL Server is dumb about paging."

Then the fix. I want the primary rewrite — the one that attacks the root cause rather than the symptom — with before/after numbers for whatever metric you decide is the one that matters here. I also want you to tell me what you'd do about that duplicate-rows bug in the backlog, and whether you think it's a separate issue or not.

Finally, three questions I want you to have an opinion on rather than just an answer to. One: I set compat level to 140 on purpose. What would change at 150, would it actually solve this, and what are the limitations of relying on it? Two: there's a hint you could add to this query that would make the symptom disappear in about ten seconds of work — name it, and tell me why you would or wouldn't ship it. Three: could you eliminate the expensive operator entirely with a schema change? Sketch it and tell me what it costs you.

A fair warning on measurement: if you test by running the proc in SSMS and reading the grid, you'll see ASYNC_NETWORK_IO show up because of one particular column in the select list, and it's a red herring — it's 50 rows, it isn't your problem. Measure the thing that's actually being contended for.

After reading the whole thing, I felt we are looking into blocking issue, even without going into details.

However, when ran queries as mentioned, they completed too quickly to monitor. Therefore, I ask for a solution. Then Claude gave me same query running 25 times in a loop (with page numbers, so not the same data returned).

This time I was able to capture the results, and this is what I got.


So my conclusion was queries are sometimes go because of the memory contention (RESOURCE_SEMAPHORE wait).

My conclusion was mostly correct and Claude has shown more information from the above result set. For example, requested time and grant time different.

After that, Claude wanted me to check the plan and see why this simple query is asking for 650 Mb memory grant.

This is the actual execution plan:



Looking at the plan, I have identified, that SQL server is feeding all column required by query from post table to sort operator. This is probably the issue here. I have also identified that duplicate row bug is caused by joining to comments table. Comments table can include multiple rows per post.

My Solution

For this problem, I could only determine how to eliminate duplicate bug. I couldn't reduce the memory grant.

Claude's Solution

Impressively Claude has shown me that, we could reduce the memory grant by breaking the query into parts.
;WITH Ranked AS
(
    -- Only what's needed to decide the order. Sort row ~20 bytes instead of 446.
    SELECT p.Id,
           EngagementScore = (p.Score * 2) + p.CommentCount,
           RowNum = ROW_NUMBER() OVER (ORDER BY (p.Score * 2) + p.CommentCount DESC,
                                                p.Id ASC)
    FROM dbo.Posts AS p
    WHERE p.PostTypeId = 1
      AND p.CreationDate >= @StartDate
      AND p.CreationDate <  @EndDate
)

First part of the query just fetches the field required for sort and sort it (see above).

PageIds AS
(
    SELECT Id, EngagementScore, RowNum
    FROM Ranked
    WHERE RowNum BETWEEN ((@PageNumber - 1) * @PageSize) + 1
                     AND  (@PageNumber * @PageSize)
)

Second part of the query, isolate the records required for the requested page (because this is paginated stored procedure).

Finally main query fetch all other fields required and also remove the duplication in Comments table.

SELECT  pg.Id AS PostId, p.Title, p.Tags, p.CreationDate, p.Score,
        p.ViewCount, p.CommentCount,
        lc.CommentText, lc.CommentDate,
        u.DisplayName AS AuthorName, u.Location AS AuthorLocation,
        u.AboutMe     AS AuthorAboutMe,
        pg.EngagementScore, pg.RowNum
FROM PageIds AS pg
JOIN dbo.Posts AS p ON p.Id = pg.Id                  -- 50 clustered seeks: the "lookups"
LEFT JOIN dbo.Users AS u ON u.Id = p.OwnerUserId
OUTER APPLY
(
    SELECT TOP (1) c.Text AS CommentText, c.CreationDate AS CommentDate
    FROM dbo.Comments AS c
    WHERE c.PostId = p.Id
    ORDER BY c.CreationDate DESC
) AS lc
ORDER BY pg.RowNum;

In question Claude ask there were 3 questions asked on my openion.

Question 1: Changing Compact level to 150

This will cause adaptive memory grant to kick in, but I said I don't want it to kick in as it will cause varied memroy grants.

Question 2: Hint which can be used.

I couldn't figure this out. Claudes answer was using MAX_GRANT_PERCENT

OPTION (MAX_GRANT_PERCENT = 5);

However, it didn't reqlly want it applied as long time solution.

Quetion 3: Schema Change to Help

I said about having sorting column (Score) as calculated column. Claude agreed and also suggested adding it part of the index.


Conclusion

Overall, this exercise was very good one. it was very impressive how Claude approached the issue and how it provided the answer step by step. Very good learning experience.


12/09/2026

SQL Server Logical Reads vs Physical Reads

When it comes to SQL Server query tuning, two of the most used phrases are logical reads and physical reads. But do you know exactly what are these, how they distinguish? It is not a very complicated thing, but recently I started explaining small things for me (and you) so when we go to complex stuff we have a strong base to sit on.

Logical Read

As a measurement, we are told always use logical reads. Logical read is a page read from the buffer pool (SQL Server's in-memory cache). Every page comes to SQL server, through buffer pool. So, it is good and stable measurement to see how much data, query is playing with.


Physical Read

Physical read is a page that had to be pulled from disk, because it is not in the buffer pool (yet). Therefore, physical reads are always subset of logical reads. When page is read from disk it is placed in buffer pool. Then SQL server read from there. 


Since data is coming from disk, more physical reads mean, more dealy.

Let's have a look into an example.

SET STATISTICS IO ON;

SELECT * FROM Sales.SalesOrderDetail
WHERE ProductID = 776;

In my computer, output was:

(228 rows affected)
Table 'SalesOrderDetail'. Scan count 1, logical reads 1128, physical reads 3, 
page server reads 0, read-ahead reads 120, 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.

If I run second time physical reads will be zero.

(228 rows affected)
Table 'SalesOrderDetail'. Scan count 1, logical reads 1128, 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.

In both occations, logical reads were same, but physical reads were 3 in first run and 0 in second run (because pages were in buffere pool in second run).

Hope that clarifies.

P.S. If you wondering about "Read-ahed reads", they are also physical reads, but it is just SQL Server being proactive. When SQL server fetch some pages from disk, if it sense, user will query data relatively close to what it fetch, it reads those pages as well and put them in buffere pool. But those read ahead pages were not used in the query that just ran.



29/08/2026

SQL Server Performance Tuning Excersises - 3

This is the 3rd part of the blog series where we discuss about SQL server performance tuning exercises generated by Claude.

This time I asked for medium level challenge like the last time. I feel shouldn't go to expert level too soon.

Claude has chosen Stack Overflow database this time also.

Steup

Interesting setup script given by Claude:

USE StackOverflow2013;   -- or StackOverflow2010, whatever you're using
GO

-- Record this first so you can put it back later
SELECT name, compatibility_level 
FROM sys.databases 
WHERE database_id = DB_ID();
GO

-- This shop did a lift-and-shift onto SQL 2019/2022 hardware
-- but never touched the compat level. Very common.
ALTER DATABASE CURRENT SET COMPATIBILITY_LEVEL = 140;
GO

As you can see above script set the compatibility level to 140, i.e. 2017, but has a comment saying they (shop in the example) did a lift and shift to 2019/2022. Then we have these two helper functions written by a developer sometimes back.

CREATE OR ALTER FUNCTION dbo.fn_GetUserAnswerCount (@UserId int)
RETURNS int
AS
BEGIN
    DECLARE @AnswerCount int;

    SELECT @AnswerCount = COUNT(*)
    FROM dbo.Posts AS p
    WHERE p.OwnerUserId = @UserId
      AND p.PostTypeId = 2;      -- 2 = Answer

    RETURN ISNULL(@AnswerCount, 0);
END
GO

CREATE OR ALTER FUNCTION dbo.fn_GetUserTier (@Reputation int)
RETURNS varchar(20)
AS
BEGIN
    DECLARE @Tier varchar(20);

    IF @Reputation >= 100000
        SET @Tier = 'Legendary';
    ELSE IF @Reputation >= 25000
        SET @Tier = 'Elite';
    ELSE IF @Reputation >= 5000
        SET @Tier = 'Trusted';
    ELSE IF @Reputation >= 1000
        SET @Tier = 'Established';
    ELSE
        SET @Tier = 'Newcomer';

    RETURN @Tier;
END
GO

Then index added by previous DBA

IF NOT EXISTS (SELECT 1 FROM sys.indexes 
               WHERE name = 'IX_Posts_OwnerUserId_PostTypeId' 
                 AND object_id = OBJECT_ID('dbo.Posts'))
    CREATE NONCLUSTERED INDEX IX_Posts_OwnerUserId_PostTypeId
        ON dbo.Posts (OwnerUserId, PostTypeId);
GO
-- Heads up: on the 2013 dataset this build takes a few minutes.

Finally, we have stored procedure use for troublesome report.

CREATE OR ALTER PROCEDURE dbo.usp_GetActiveContributorReport
    @MinReputation    int,
    @LastAccessSince  datetime
AS
BEGIN
    SET NOCOUNT ON;

    SELECT  u.Id,
            u.DisplayName,
            u.Reputation,
            u.LastAccessDate,
            dbo.fn_GetUserAnswerCount(u.Id)  AS AnswerCount,
            dbo.fn_GetUserTier(u.Reputation) AS ReputationTier
    FROM    dbo.Users AS u
    WHERE   u.Reputation     >= @MinReputation
      AND   u.LastAccessDate >= @LastAccessSince
    ORDER BY u.Reputation DESC;
END
GO

Scenario

The "Active Contributors" page in the internal admin tool takes between 40 and 90 seconds to load. It used to be quicker when the site was smaller, and it's been getting steadily worse. Nobody changed the code. CommandTimeout is set to 120 in the .NET app, so it doesn't actually time out — it just makes everyone hate the page.

From the DBA. "I checked the plan and the estimated cost is about 2.5. That's nothing. This query doesn't even show up in our 'top 20 most expensive queries by cost' report. But it's sitting at number one in Query Store for total CPU time, by a mile." She also mentions the plan is completely serial no matter what — she tried slapping OPTION (MAXDOP 8) on it and the plan shape didn't budge. MAXDOP on the server is 8, cost threshold is 50.

From the sysadmin. During the report run, one CPU core pins at 100% and the other seven are idle. Disk queue length is flat. No blocking, no lock waits.

From the developer who wrote it. "I ran SET STATISTICS IO ON and it barely shows anything — some reads on Users and that's basically it. The Posts table hardly registers, which is weird because the report is obviously counting posts." He also points out that if he runs the inner count for a single user by hand:

SELECT COUNT(*) FROM dbo.Posts WHERE OwnerUserId = 22656 AND PostTypeId = 2;

...it returns instantly, single-digit milliseconds. So in his mind the function can't possibly be the problem.

What Claude expected from me

Get the procedure returning the same result set in a couple of seconds on warm cache, and — more importantly - be able to explain why every cost-based diagnostic in the box lied to you about this query.

A few things I want you to actually measure rather than assume: total worker time before and after (sys.dm_exec_procedure_stats or Query Store, not wall clock), whether the plan goes parallel after your fix, and what the real logical read count on Posts was all along.

Why did the plan never go parallel, and what specifically about one of those two functions caused it? (Only one of them is guilty of this.) Why did the plan never go parallel, and what specifically about one of those two functions caused it? (Only one of them is guilty of this.)

And finally: once the function problem is gone, what's the next bottleneck in this query? There's one more thing in there worth fixing.

My Solution

Here is my version of the stored procedure:

ALTER   PROCEDURE [dbo].[usp_GetActiveContributorReport_MDA]
    @MinReputation    int,
    @LastAccessSince  datetime
AS
BEGIN
    SET NOCOUNT ON;

    CREATE TABLE #tempPosts (OwnerUserId int PRIMARY KEY, AnswerCount int)
    INSERT INTO #tempPosts
    SELECT  OwnerUserId, COUNT(*)
    FROM dbo.Posts AS p
    WHERE p.PostTypeId = 2
    GROUP BY OwnerUserId

    SELECT  u.Id,
            u.DisplayName,
            u.Reputation,
            u.LastAccessDate,
            tp.AnswerCount,
            CASE 
                WHEN u.Reputation >= 100000 THEN 'Legendary'
                WHEN u.Reputation >= 25000 THEN 'Elite'
                WHEN u.Reputation >= 5000 THEN 'Trusted'
                WHEN u.Reputation >= 1000 THEN 'Established'
                ELSE 'Newcomer'
            END As ss
    FROM    dbo.Users AS u INNER JOIN #tempPosts tp ON u.Id = tp.OwnerUserId
    WHERE   u.Reputation     >= @MinReputation
      AND   u.LastAccessDate >= @LastAccessSince
    ORDER BY u.Reputation DESC;
END

I told Claude following:

  • Plan didn't go parallel even after the modification. Probably due to lack of data volume in 2010 version of the stack overflow database.
  • The reason original plan has not gone parallel was because there were UDF function (both functions are causing issues not one).
  • Further improvements can be gain by adding more indexes to Users table.
  • The reason why actual stats were hidden, was because they were behind UDF function.
  • My stats:

Claude's Answer

ALTER PROCEDURE dbo.usp_GetActiveContributorReport_MDA
    @MinReputation    int,
    @LastAccessSince  datetime
AS
BEGIN
    SET NOCOUNT ON;

    CREATE TABLE #tempPosts
    (
        OwnerUserId int NOT NULL PRIMARY KEY,
        AnswerCount int NOT NULL
    );

    INSERT INTO #tempPosts (OwnerUserId, AnswerCount)
    SELECT p.OwnerUserId, COUNT_BIG(*)
    FROM   dbo.Posts AS p
    WHERE  p.PostTypeId = 2
      AND  p.OwnerUserId IS NOT NULL
    GROUP BY p.OwnerUserId;

    SELECT  u.Id,
            u.DisplayName,
            u.Reputation,
            u.LastAccessDate,
            ISNULL(tp.AnswerCount, 0) AS AnswerCount,
            CASE
                WHEN u.Reputation >= 100000 THEN 'Legendary'
                WHEN u.Reputation >=  25000 THEN 'Elite'
                WHEN u.Reputation >=   5000 THEN 'Trusted'
                WHEN u.Reputation >=   1000 THEN 'Established'
                ELSE 'Newcomer'
            END AS ReputationTier
    FROM    dbo.Users AS u
    LEFT JOIN #tempPosts AS tp ON tp.OwnerUserId = u.Id
    WHERE   u.Reputation     >= @MinReputation
      AND   u.LastAccessDate >= @LastAccessSince
    ORDER BY u.Reputation DESC;
END

Note that Claude has put p.OwnerUserId IS NOT NULL into the where clause of the select statement where we fetch posts. Its argument was, since OwnerUserId is primary key and if there are records with no OwnerUserId this will fail. I think fair enough argument.

Second different was, LEFT JOIN was used with temp table instead of INNER JOIN. This something I missed, in a hurry. Since I used INNER join, some of the records (where there were no answers) has missed. In my result set I had 102084 records, where Claude has 168634 records. That's a good catch.

Third different was, ISNULL(tp.AnswerCount, 0) As AnswerCount. This is go hand in hand with above LEFT outer join, because if answer count is null, it will need to appear as 0.

In it's answer, it admitted, it got wrong about just one function being the cause of procedure not going parallel. Following code shows, both functions could be in-lined in higher compatibility levels.


SELECT OBJECT_NAME(object_id), is_inlineable
FROM   sys.sql_modules
WHERE  object_id IN (OBJECT_ID('dbo.fn_GetUserAnswerCount'),
                     OBJECT_ID('dbo.fn_GetUserTier'));

So changing compatibility level 150 will auto improve the stored procedure without a single line of code change. But there are lot of counter arguments against function in-lining feature, so I would still go for hand made stored procedure.

Claude in it's solution, talk about OUTTER APPLY vs pre-aggregation (which we used). But OUTTER apply is subject to parameter sniffing (in this case), so I would careful about it.

Conclusion

It was a good solution from Claude and good explanation. But notice LLMs are still making some mistakes (function in-lining scenario). Note that I have used Opus 5 (High) in this scenario, which one of the most capable models. So, we need to use them carefully. I would use AI in query tuning any day, but I will be careful and analyse the solutions it gives before apply to production.


19/08/2026

WHERE and HAVING clauses in T-SQL

Recent query I have worked on got me wondering what is the different between WHERE clause and HAVING clause in a T-SQL statement which do group by.

In simplest terms:

WHERE -> row level filter (eliminate rows not qualified for)

HAVING -> group filter (eliminate entire groups)

It get clear when you understand the logical query processing order in SQL Server.


FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY

As shown above, WHERE come before the group by, which mean query processor is eliminating rows as soon after it fetch rows from the store.

Remaining rows are then grouped.

Grouped and summarized (aggregated) rows then flow into HAVING, where they are get further filtered.

Finally, we select columns and order them.

Above is not to get confused with execution plan, this is not the order plan is physically executed.

Let us consider a example:

CREATE TABLE dbo.Orders
(
    OrderId     INT IDENTITY PRIMARY KEY,
    CustomerId  INT           NOT NULL,
    OrderDate   DATE          NOT NULL,
    Status      VARCHAR(20)   NOT NULL,
    Amount      DECIMAL(10,2) NOT NULL
);

INSERT INTO dbo.Orders (CustomerId, OrderDate, Status, Amount)
VALUES
    (1, '2026-01-10', 'Completed', 500.00),
    (1, '2026-02-14', 'Completed', 300.00),
    (1, '2026-03-02', 'Cancelled', 900.00),
    (2, '2026-01-20', 'Completed', 150.00),
    (2, '2026-02-25', 'Cancelled', 500.00),
    (3, '2026-01-05', 'Completed', 750.00),
    (3, '2026-03-11', 'Completed', 400.00);

Our query is following:

SELECT  CustomerId,
        SUM(Amount) AS TotalSpend,
        COUNT(*)    AS OrderCount
FROM    dbo.Orders
WHERE   Status = 'Completed'      -- row-level filter, runs first
GROUP BY CustomerId
HAVING  SUM(Amount) > 600;        -- group-level filter, runs after aggregation

In our query, WHERE clause is: Status = 'Completed'

This filter out 2 rows from the initial fetch which belongs to customer 1 and 2.

Rest goes to Group by clause. After grouping we are left with following:

Customer Id TotalSpend OrderCount
1 800 2
2 150 1
3 1150 2

Above data set will flow into HAVING clause. HAVING clause has following condition: 

SUM(Amount) > 600 (i.e. TotalSpend > 600)

Which means customer two's group will be completely eliminated in HAVING phase.

If we had just HAVING not WHERE clause, group two will appear in the final result set as it get TotalSpend of 650.

Also worth noting that COUNT(*) only count filtered rows from WHERE clause (see OrderCount number in above table).

HAVING without GROUP BY works

Another related thing to note is HAVING works without GROUP BY. In this case whole data set flowed from WHERE clause is considered as one group.

SELECT SUM(Amount) AS TotalSpend,
        COUNT(*)    AS OrderCount
FROM    dbo.Orders
WHERE   Status = 'Completed'      -- row-level filter, runs first
HAVING  SUM(Amount) > 600;        -- group-level filter, runs after aggregation

This returns only one row (one group), with OrderCount = 5, because WHERE clause has eliminated cancelled orders.

Field Alias Usage

Field alias cannot be used in HAVING or even in WHERE clause. That is because HAVING and WHERE clause comes before SELECT. But you can use alias fields in ORDER BY.

See the query processing order given in the start of the this blog and you will understand why.

Final Word

If the condition is about a single row, it belongs in WHERE. If it's about the group as a whole, it belongs in HAVING. And filtering early in WHERE is generally cheaper too, since there's less data to sort and aggregate.

Hope you learn something, even it is simple. Having strong knowledge in simple stuff helps you to grab advanced stuff.


15/08/2026

SQL Server Performance Tuning Excersises - 2

This is the 2nd part to of the series of blogs where we discuss Performance Tuning Excersises generate by Claude.

Setup

Require StackOverflow database 2010 (10GB) or 2013 (50GB) version.

You can find the download instructions for StackOverflow database in this Brent Ozar page.

Following script create indexes required.

-- Start from a known state
DROP INDEX IF EXISTS IX_Users_Reputation ON dbo.Users;
DROP INDEX IF EXISTS IX_Posts_OwnerUserId ON dbo.Posts;
GO

CREATE INDEX IX_Users_Reputation
    ON dbo.Users (Reputation)
    INCLUDE (DisplayName);

CREATE INDEX IX_Posts_OwnerUserId
    ON dbo.Posts (OwnerUserId)
    INCLUDE (Score);
GO

-- Fresh, fullscan stats so you can't blame stale statistics.
-- This matters: I want to take that explanation off the table up front.
UPDATE STATISTICS dbo.Users WITH FULLSCAN;
UPDATE STATISTICS dbo.Posts WITH FULLSCAN;
GO

Then we create the stored procedure:

CREATE OR ALTER PROC dbo.rpt_TopContributors
    @MinReputation INT
AS
BEGIN
    SET NOCOUNT ON;

    SELECT TOP (100)
           u.Id,
           u.DisplayName,
           u.Reputation,
           COUNT_BIG(p.Id) AS PostCount,
           MAX(p.Score)    AS BestPostScore
    FROM dbo.Users AS u
    JOIN dbo.Posts AS p
        ON p.OwnerUserId = u.Id
    WHERE u.Reputation >= @MinReputation
    GROUP BY u.Id, u.DisplayName, u.Reputation
    ORDER BY PostCount DESC;
END
GO

Note that in here we are filtering based on reputation points. So, we need two end of reputation points to check the stored procedure. In here Claude has picked 100000 as high reputation point, which means only few users are returned and 10 has the low reputation threshold, which return most of the users in Users table.

So, our test execution script will be:

SET STATISTICS IO, TIME ON;

EXEC sp_recompile 'dbo.rpt_TopContributors';  -- safer than FREEPROCCACHE
EXEC dbo.rpt_TopContributors @MinReputation = 100000;
EXEC dbo.rpt_TopContributors @MinReputation = 10;

In round 1 of testing, we run high reputation threshold first and then lower reputation (as per above).

Result Set 1: @MinReputation = 100000 (compiled with @MinReputation = 10000): 

Table 'Posts'. Scan count 453, logical reads 2622, 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 'Users'. Scan count 1, logical reads 6, 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 = 110 ms,  elapsed time = 240 ms.

 SQL Server Execution Times:
   CPU time = 110 ms,  elapsed time = 245 ms.


Result Set 2: @MinReputation = 10 (Compiled with @MinReputation = 100000): 
Table 'Posts'. Scan count 234232, logical reads 755528, 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 'Users'. Scan count 1, logical reads 1010, 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 = 1593 ms,  elapsed time = 1796 ms.

 SQL Server Execution Times:
   CPU time = 1593 ms,  elapsed time = 1796 ms.


In round 2 of testing, we run min reputation threshold first and then higher reputation second.

Result Set 3: @MinReputation = 10 (compiled with @MinReputation = 10): 

Table 'Users'. Scan count 0, logical reads 318, 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 19, 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 'Posts'. Scan count 1, logical reads 8334, 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 = 750 ms,  elapsed time = 971 ms.

 SQL Server Execution Times:
   CPU time = 765 ms,  elapsed time = 1034 ms.


Result Set 4: @MinReputation = 100000 (compiled with @MinReputation = 10): 
Table 'Users'. Scan count 0, logical reads 342, 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 19, 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 'Posts'. Scan count 1, logical reads 8334, 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 = 735 ms,  elapsed time = 875 ms.

 SQL Server Execution Times:
   CPU time = 735 ms,  elapsed time = 875 ms.


My thoughts:

In above I have used StackOverflow 2010 database.

Looking at the results, we can see result set 2, i.e. executing with @MinReputation = 10 when stored procedure compiled with @MinReputation = 100000 is the worst scenario. Although time wise it is not much visible in 2010 version of the stack overflow database, it has significantly more logical reads (about 755000 in my case). 

If we look at the execution plan for first two result sets, it starts with seeking into reputation index. It is because SQL server thought there will be very small number of users with reputation higher than 100000 (because plan is compiled with @MinReputation = 100000). So, it only found few with reputation higher than 100000. But when we ran with @MinReputation = 10, reputation index returned lot more users (234232 user in my case). In the second part of plan SQL server index seek on each of those users return. This index seek was ok when there were less users (less number of seeks), but for large number of users it is too much. This is why you see performance degrade.

If plan was compiled with @MinReputation = 10 (result set 3 and 4), we can see different shape of plan. In this case SQL server, choose to scan the entire post owners index and sort it on post count. Then for each group (group by OwnerUserId), it seeked into the users table, until it finds 100 users (to full fill TOP 100 users).

For result set 3 and 4, logical reads and cpu time is mostly similar.

My Solution

Knowing that result set 3 and 4 behaved fairly equally not depending on parameter, I suggest we optimize the plan for @MinReputation = 10. So, no matter which parameter we use, it will use the plan compiled for @MinReputation = 10.

ALTER   PROC [dbo].[rpt_TopContributors]
    @MinReputation INT
AS
BEGIN
    SET NOCOUNT ON;

    SELECT TOP (100)
           u.Id,
           u.DisplayName,
           u.Reputation,
           COUNT_BIG(p.Id) AS PostCount,
           MAX(p.Score)    AS BestPostScore
    FROM dbo.Users AS u
    JOIN dbo.Posts AS p
        ON p.OwnerUserId = u.Id
    WHERE u.Reputation >= @MinReputation
    GROUP BY u.Id, u.DisplayName, u.Reputation
    ORDER BY PostCount DESC
    OPTION (OPTIMIZE FOR (@MinReputation = 10));
END

Above is my solution, note the use of OPTIMIZE FOR hint.

Claude Solution:

Well Claude has accepted my solution, but told me to OPTIMIZE for UNKNOWN. But ideal solution it suggested is using "temp" table to fetch users first and then query posts. To do this query will be split to two. See below:

ALTER   PROC [dbo].[rpt_TopContributors]
    @MinReputation INT
AS
BEGIN
    SET NOCOUNT ON;

    SELECT u.Id,
           u.DisplayName,
           u.Reputation
    INTO #tempUsers
    FROM dbo.Users u
    WHERE u.Reputation >= @MinReputation

    SELECT TOP (100)
           u.Id,
           u.DisplayName,
           u.Reputation,
           COUNT_BIG(p.Id) AS PostCount,
           MAX(p.Score)    AS BestPostScore
    FROM #tempUsers AS u
    JOIN dbo.Posts AS p
        ON p.OwnerUserId = u.Id
    GROUP BY u.Id, u.DisplayName, u.Reputation
    ORDER BY PostCount DESC
END

Running above shows fairly low number of logical reads and cpu time for all parameter combinations.

Also Claude has mentioned with temp table usage, I get automatic recompilation, based on the number of rows it retrieved into the temp table. This was something I didn't thought of.

29/07/2026

SQL Server Performance Tuning Excersises - 1

Couple of weeks ago, I blogged about how I tried to use AI (Claude) to teach me performance tuning. You can read it here.

Starting from this blog I'm going to share those exercises and what I have learned from them.

I'm modifying what Claude has outputted to looks like it is actual exercise in here.

Setting Up Data

Below assume you have already downloaded and setup some version of AdventureWorks sample database by Microsoft.

The scenario runs on a table modelled after AdventureWorks sales data, but we need to build an inflated copy, so the performance difference is actually felt rather than just theoretical — the stock SalesOrderHeader is only ~31K rows, which is too small to show anything meaningful.

Step 1: Create "SalesOrders" table.

IF OBJECT_ID('dbo.SalesOrders') IS NOT NULL DROP TABLE dbo.SalesOrders;
GO

CREATE TABLE dbo.SalesOrders
(
    SalesOrderID    INT IDENTITY(1,1) NOT NULL,
    OrderDate       DATETIME          NOT NULL,
    CustomerID      INT               NOT NULL,
    TotalDue        MONEY             NOT NULL,
    OrderStatus     TINYINT           NOT NULL,
    OnlineOrderFlag BIT               NOT NULL,
    CONSTRAINT PK_SalesOrders PRIMARY KEY CLUSTERED (SalesOrderID)
);
GO

Step 2: Insert lot of dummy data, by cross joining sys.all_objects.

INSERT INTO dbo.SalesOrders (OrderDate, CustomerID, TotalDue, OrderStatus, OnlineOrderFlag)
SELECT TOP (1500000)
    DATEADD(DAY, ABS(CHECKSUM(NEWID())) % 2920, '2019-01-01'), -- ~8 years of dates
    ABS(CHECKSUM(NEWID())) % 20000 + 1,
    CAST(ABS(CHECKSUM(NEWID())) % 100000 / 100.0 AS MONEY),
    ABS(CHECKSUM(NEWID())) % 8 + 1,
    ABS(CHECKSUM(NEWID())) % 2
FROM sys.all_objects a
CROSS JOIN sys.all_objects b;
GO

Step 3: Create a index which simulate real world scenario where users already have some indexes.

CREATE NONCLUSTERED INDEX IX_SalesOrders_OrderDate
ON dbo.SalesOrders (OrderDate)
INCLUDE (CustomerID, TotalDue);
GO

Step 4: Create the stored procedure which we will be tuning.

CREATE OR ALTER PROCEDURE dbo.GetOrdersByYear
    @Year INT
AS
BEGIN
    SET NOCOUNT ON;

    SELECT SalesOrderID, OrderDate, CustomerID, TotalDue
    FROM dbo.SalesOrders
    WHERE YEAR(OrderDate) = @Year
    ORDER BY OrderDate;
END
GO


Scenario

There's a "Sales by Year" report page in the app that calls dbo.GetOrdersByYear with a single year, e.g. EXEC dbo.GetOrdersByYear @Year = 2022;. 

Users are complaining that this report is sluggish and keeps getting slower the longer the system's been live — it used to feel snappy when the table was small, now it's a few seconds and climbing. 

The DBA is confused because there's a perfectly good index sitting on OrderDate that includes the exact columns the query returns, yet it doesn't seem to be helping. CPU also ticks up noticeably whenever the report runs, even though each call only returns one year's worth of rows.

Exercise

Run the proc with the actual execution plan on and SET STATISTICS IO, TIME ON, then come back with three things: 

  1. The root cause (why that index isn't being used the way you'd hope)
  2. Your fix (the rewritten proc)
  3. The evidence that it worked (before/after logical reads, plan operator change, and duration) 

Keep the proc's signature the same; the caller shouldn't have to change how it invokes it.

The thing to watch for: the fix here shouldn't require adding any new index. The right index already exists — the query just isn't letting SQL Server use it properly. 

-----------------------------------------

I suggest you give a shot at above exercise. Then look for my solution below.

-----------------------------------------

First thing I did was, as per instructions execute and see how it works currently.

SET STATISTICS IO, TIME ON
GO

exec dbo.[GetOrdersByYear] '2022'

I got following execution plan:


As you can see it is using the index, but doing an index scan operation.

Stats as follows:

 SQL Server Execution Times:
   CPU time = 0 ms,  elapsed time = 0 ms.
Table 'SalesOrders'. Scan count 1, logical reads 5597, physical reads 1, page server reads 0, read-ahead reads 5603, 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 = 219 ms,  elapsed time = 3593 ms.

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

It was not bad, just 4 seconds for report query? I was wondering. But as mentioned on the exercise text, this is increasingly taking longer. So it seems like, more data on the table it gets more time to execute.

Looking at the execution plan it was obvious why that is happening. More data, more time to scan.

So why does it scan, when you have a index on date column and it is a covering index (i.e. provide all columns query need)?

So, I opened up the stored procedure to check. That's when I realized the issue.

Query is not SARGable. Input parameter was integer (year), so SQL server had to find the year using a "YEAR" function, which made query not SARGable. This is why index scan was used. Because SQL server couldn't predict which rows to seek into.

Now that I have understood issue, fix was to make the query more SARGable. Simlest technique is converting the incoming parameter into a date range and query the date rage. When using date rage, SQL server will be able to seek into the index correctly.

Here is my solution

ALTER   PROCEDURE [dbo].[GetOrdersByYear_MDA]
    @Year INT
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @startDate datetime = DATEFROMPARTS(@Year, 1, 1)
    DECLARE @endDate datetime = DATEFROMPARTS(@Year + 1, 1, 1)

    SELECT SalesOrderID, OrderDate, CustomerID, TotalDue
    FROM dbo.SalesOrders
    WHERE OrderDate >= @startDate AND OrderDate < @endDate
    ORDER BY OrderDate;
END

New execution plan:


New stats:




 SQL Server Execution Times:
   CPU time = 0 ms,  elapsed time = 0 ms.
Table 'SalesOrders'. Scan count 1, logical reads 705, physical reads 2, page server reads 0, read-ahead reads 709, 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 = 32 ms,  elapsed time = 3366 ms.

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

Note that logical reads are down from 5597 to 705 and CPU time is 32 ms against 219 ms in previous round. Although network IO time was bit same and query executed about same time.

To be honest in the first round I used "Date" variables rather than "DateTime" variable in the new stored procedure. But claude has pointed me that data type conversion will consume bit of CPU time and we can optimize more if we use "DateTime". And that is a very good suggestion.

That was not a bad first exercise. Hope to get more complex one next.

18/07/2026

Claude as your Teacher

I've been focusing and studyingon SQL server performance tuning for more than a year now. I was involved in performance tuning task before, but I wanted to be an expert on that area. So I have been following online courses and doing some self-studies and reading books in my spare time.

However, I still feel I lack real world work, which require to be an expert in that field. Best way to get real world experience is find a job on that area. But in my current job I get very little performance tuning experience. I do get lot more SQL development experience. So do use my knowledge on performance tuning when I develop SQL stuff (e.g. stored procedures). 

This led me to search for some real-world experience, without leaving my current job. I did find some information on internet, but didn't find much useful or catered for me (mostly because performance tuning is case by case issue, I think).

Idea came to me suddenly recently, what if I can use AI tool as my real world experience provider. So I started working with Claude to get something developed. I used "Project" feature in Claude chat.

Let us first have a quick look into Claude Project feature.

You can find Project in Claude in left hand side menu:


Claude Projects has 3 components:

  • Memory -> This is something claude autogenerate, while you work on the project. But you can edit if you noticed something is not correct.
  • Instructions -> Most important part. You can give instructions specific to this project and Claude will follow this everytime it do a task within this project.
  • Files -> Any additional knowledge you want to give to claude on the subject.

Your recent chats relate to this project appear under "Recents" section.

So how did I get Claude to give me (or rather teach me) performance tuning exercises?

I gave following prompt/instruction on the project:

I want to challenge my self by resolving various SQL server performance issues. In order to do this I want you to think like a SQL server performance tuning expert and generate me scenarios with performance issues. I want you to generate these challenges in 3 difficult levels -> Easy, Medium and Expert. When I'm ready, I'm going to ask you to generate a scenario and present me in a particular difficult level. For these challenges, you are going to use AdventureWorks sample database provided by Microsoft (which is publicly available) and StackOverflow database which is also available publicly. I want to generate a query or stored procedure or function with a issue and just tell me what users experience when they use it (issues they face). For this you might also need to give some context information also (e.g. parameters particular stored procedure is running, indexes which are already in use) to me. You might also need to give some script to setup the data and other objects to match the scenario in my testing database (e.g. create or drop index scripts). Once you give me the challenge, I will resolve it and present you the solution, for your evaluation. 

Then on project chat area, I gave following prompt:

Please read the instructions given and present me with Easy challenge.

I'm actually really impressed with results. It gave me a scenario where I have to inspect a slow running stored procedure and identify the issue and give solution for it. Not only it gave me a scenario and excersise, it gave me instructions to setup the database in a manner where the issue is occurring.

I'm not going to explain the whole exercises here as it will make this blog very long. But here are the highlights.

In Step 1, it gave me a script to setup the database. Claude used AdventureWorks database. Because default data didn't cater for this exercise, it gave a script to create alternative "SalesOrders" table with about 1.5 million rows in it.

In Step 2, it continued the setup and told me to create an index in a specific way.

In Step 3, it gave me script to create the troublesome stored procedure.

Finally Claude explained the scenario. Here are the actual text:

There's a "Sales by Year" report page in the app that calls dbo.GetOrdersByYear with a single year, e.g. EXEC dbo.GetOrdersByYear @Year = 2022;. Users are complaining that this report is sluggish and keeps getting slower the longer the system's been live — it used to feel snappy when the table was small, now it's a few seconds and climbing. The DBA is confused because there's a perfectly good index sitting on OrderDate that includes the exact columns the query returns, yet it doesn't seem to be helping. CPU also ticks up noticeably whenever the report runs, even though each call only returns one year's worth of rows.

What I want from you:
Run the proc with the actual execution plan on and SET STATISTICS IO, TIME ON, then come back with three things — the root cause (why that index isn't being used the way you'd hope), your fix (the rewritten proc), and the evidence that it worked (before/after logical reads, plan operator change, and duration). Keep the proc's signature the same; the caller shouldn't have to change how it invokes it.  


Not a very bad exercise to start with, isn't it?

I was able to resolve it. When I presented the solution, for my surprise, it showed me some minor things (e.g. data type conversion, which I didn't think of in this situation as it didn't contribute much), I missed.

I'm going to continue to use it. Hoping to blog a about each exercise, and my solution, so you can also learn something from it. If you have any ideas to improve this project, please comments under this blog.


01/07/2026

I LIKE it with ESCAPE

Most of you who are in TSQL world like me have used LIKE operator to find strings that not exactly match or find a row which contains a specific word.


For example, if you want to find names that starts with "ST", you would do someting like this in TSQL:

SELECT DisplayName FROM dbo.Users

WHERE DisplayName LIKE 'ST%'

This will bring display names like "Stephen", "Stanley", "Stone" etc.

But recently, I came across neat trick I can use with LIKE operator in TSQL, specially when using wild card charaters.

LIKE operator accept several wild card characters:

  • % matches any string of zero or more characters (this is the most used)
  • _ matches exactly one character
    • LIKE 'ST_' => matches => ST5, STT, ST3, etc (exactly one character after ST)
  • [...] matches any single character in a set or range (like [a-f])
    • LIKE 'ST[a-f]phen => matches => STaphen, STbphe, STcphen, STdphen, STephen, STfphen (characters from a to f)
  • [^...] matches any single character not in a set or range
    • Similar to above, but it is NOT match
When you using wild card characters like this, what if search string contains, wild card characters in it and you want to search fo them?

That is where ESCAPE hint comes handy.

For example let us consider following:

SELECT * FROM Products WHERE ProductCode LIKE '%_DISCONTINUED%';

In here we want to search for product codes, ended with "_DISCONTINUED" E.g. CAT1_DISCOUNTINUED, LOSS_DISCOUNTINUED. In other words we need "_" character (which LIKE operator consider as wild card character) in the search string.

In that scenario, we can change the T-SQL like below:

SELECT * FROM Products WHERE ProductCode LIKE '%\_DISCONTINUED%' ESCAPE '\';

This tells SQL parser, we are using "\" character as escape character and we escaping "_"character and telling LIKE operator to include it in the search.

If we don't use escape character, LIKE "%_DISCOUNTINUED%" will result in results like "ADISCOUNINUED", "IGNOREMEDISCOUNTINUED" which we really don't want.

Now \_ means "a literal underscore," and the query only matches rows where that underscore is actually there. The ESCAPE '\' clause is what tells SQL Server "hey, whenever you see a backslash in this pattern, treat the next character as literal, not special."

You don't always have to use ackslash, by the way — any single character works, as long as it's one you're not otherwise using in the pattern:

SELECT * FROM Products WHERE ProductCode LIKE '%!_DISCONTINUED%' ESCAPE '!';

Above T-SQL also brings same results.

Consider a example you want to find values like 50%.

SELECT * FROM Discounts WHERE Description LIKE '%50\%%' ESCAPE '\';

In above example you can see two % signs together, one sign tells SQL server next character appear need escaping from wild card treatment, there fore looking for 50%.

This setting is per query, andn will not effect all queries you execute after that.

Hope you have learned something new today like me.

Creating Videos for Tutorial using Remotion and Claude AI

I always believe, picture worth thousand words and video worth even more when it comes to do tutorials.

Recently I found a tool called Remotion.




Remotion is a short video generating app which uses React at the core. It is programmatic way to generate videos. Because it is programmatic, it works well with AI agents. This is why it has got my attention.

Therefore, in blog I'm going to show how remotion can be used with Claude.ai.

Since we are going to use Remotion with Claude.Ai, we need Claude code installed on the machine we are intended to use Remotion. Note that Claude code require paid subscription to work.

Another pre-requisite is Node.js. Install the latest Node.js (remotion will require v16 or higher).


Installation

Create a folder for your project and open that folder using terminal.

Type following:

npx create-video@latest

Follow the wizard in terminal.

Wizard will ask you to choose a template, choose the blank template as we ae just getting started and we intend to use this with Claude code.

Wizard will ask you to install TailWindCSS or not. For our beginner project, I would say no. But it is completely optional, choosing yes is also ok, but add more complexity.

Next question is "Add agent skills?". Definitely answer yes for this question.

You will be asked to choose which agent to get skills installed for. For us it is Claude code.

Then you will require to choose installation scope. I would keep it to project. You can choose Global if you prefer to install it once.

Installation method for skill is "Symlink"

Then wizard will install the skills and complete the installation.

Next step is to install all packages. To this run following:

npm i

Once all packages are installed, you can run the app using following command:

npm run dev

There will be nothing as we haven't add any frames.


Setting up Claude Code

Next step is to run Claude code on the projects folder.

Assuming you are still on the project folder in terminal type following to invoke Claude Code:

Claude

Assuming everything is setup correctly on Claude code, above will open Claude code in project folder.

run /init to initialize Claude code for the project. This will read the content of the folder and add the remotion skill and will create claude.md file for the project. Once this is done Claude is ready for your instructions.


Prompt

Now you provide the prompt to Claude code to build the video. More elaborate the prompt is more sophisticate your video will be.

To make this demo easier, I have asked following from Claude.ai (chat box) and create the prompt for me.

Create a prompt which I can give for Claude code to create simple video tutorial for SQL Server Constraint types using "Remotion". This is for remotion project. See remotion information on https://www.remotion.dev/ . SQL server Constraint video should be based on following blog post -> https://mpa-tech-tales.blogspot.com/2026/05/all-constraints-in-sql-server.html . It should have attractive opening screen and then will need several animating screens based on sections of the blog post. For example it should show a one animation screen explaining what are sql server constraints and then transit from that screen to next screen to show primary key constraints and related animations. Can you please generate the prompt for this?

It has created very comprehensive prompt, which I can paste or give Claude Code as a md file.

If the prompt is so big, it is advisable to instruct Code Claude to proceed phase by phase (after validating output of each phase). This will make sure Claude keep on the track we wanted instead of what it wanted.

Here is the first draft of the video created by Claude Code and remotion, using above prompt. It is not any mean polish video I wanted, but you can keep refine it with Claude Code to make it more preferable. 

In my opinion (as per now), Remotion + Claude can create you basic animation videos which you can use for tutorials or presentations. I think more appropriate use case is use it to create short animations which can be embedded/merge into your overall video, rather than making entire video from Remotion + Claude.

Schema Compare in SSMS 22.7.0

One of the coolest features released lately with SSMS (SQL Server Management Studio) is Schema Compare feature, which released on version 22.7.0.

To me it is one of anticipated feature. Yes, I know we had commercial products like Redgate SQL compare, but most organisations I worked with (small to mid size), doesn't have budget to afford that (or don't want to spend money on that).

There fore I had quick look at what it can do.

Currently it can be invoked through "Tools" menu in SSMS.


Worth noting, this feature is still in "Preview" mode, so it is not fully production ready and might not compare all of the database objects.

Schema Compare UI is as follows:


It has two main panes, one in top (showing what it compare) and one in bottom showing differences in objects when selected.

Optoins button give ability customize your comparison. When pressed following dialog box is opened:

Dialog box has two tabs.

  1. General Options
  2. Object types to compare

General options tab give you control over the comparison process and change script generation process. It gives options such as "Ignore ANSI NULL". Object Types to compare tab allows you to select which objects to compare:


You can compare following database types:
  • Databses
  • Databse Projects
  • Data-tier Application Files (dacpac)
As the source you can select any of these.


In my demo I have selected two versions of Stack Overlow database:


Then press "Compare" button.

Comparison takes little while, if you database has thousands of ojects, it will definitely take considerable time. I think this is something they need to focus on before release to production.

Here are the results from comparison:


It shows deleted table in target table -> dbo.LegacyPostVotes. New table in source database -> dbo.Badges. And also shows several changes. If you click on one of these lines, you can see detail view of the change. For example following screenshot shows, what appear when I select db.usp_GetUserById stored procedure, which was changed:


You can include/exclude changes you want or don't want. Then press either "Generate Script" button or "Apply" button. Generate Script button, generates the change script and open in a new Script Window. Apply button directly apply included changes into target database.

In my openion, It is not perfect, but start to walk write direction I would say.

I'm hoping to test this more with future releases and write about it.










SQL Server Performance Tuning Excersises - 4

This is the 4th part of the blog series where we discuss about SQL server performance tuning exercises generated by Claude. This time I aske...