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.

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 ar...