Query Tuner – RockyPC / SQLStorm / 0x932168333A069240 – 2026-05-31
Tuning goal: Query TunerEstimated cost: 20.6294 (Medium)Confidence: Medium-High
Platform supports online index operations, advanced compression, columnstore, and SQL Server 2022 IQP features. This query does not use parameters, so parameter sniffing and PSP are not applicable for this specific statement.
Executive summary
1) Add a Comments index on (PostId) INCLUDE (Id, UserId)
This is the highest-impact change. It should remove the expensive Eager Spool and reduce two separate full/large scans of Comments.
Evidence: plan shows Index Spool (Eager Spool) with estimated subtree cost 15.0741; the spool is fed by a Clustered Index Scan of Comments reading 351,440 rows. Missing index recommendation for Comments(PostId) shows Impact=40.0023%.
2) Add a filtered Votes index for bounty rows
A filtered nonclustered index on Votes(PostId) INCLUDE (BountyAmount) WHERE VoteTypeId = 8 should replace a scan of all vote rows with a tiny seekable structure.
Evidence: the plan scans Votes with EstimatedRowsRead=926,084 to return only 1,478 rows for VoteTypeId = 8. Missing index recommendation exists for Votes.
3) Rewrite UserReputation to aggregate Posts and Badges separately
The current CTE multiplies rows by joining Users → Posts → Badges before aggregation, forcing COUNT(DISTINCT) and a large hash aggregate.
Evidence: plan contains Hash Match (Aggregate) with COUNT(DISTINCT [Posts].[Id]) and multiple hash joins over scans of Users, Posts, and Badges.
4) Add a Posts index aligned to the date filter
A covering index on Posts(CreationDate, OwnerUserId) INCLUDE (Id, Title) can reduce scan volume for RankedPosts and lower sort/hash input.
Evidence: the plan scans Posts and reads 246,672 rows to estimate 8,351.92 qualifying rows for the date predicate.
- Implement the Comments and Votes indexes first. These target the largest avoidable scans and the spool-heavy
EXISTSbranch. - Rewrite the query second. The rewrite removes row multiplication and should reduce hashes, sorts, and compilation complexity.
- Add the Posts date-oriented covering index if the rewritten query still scans Posts heavily.
Workload context
| Area | Observation | Why it matters |
|---|---|---|
| Overall plan cost | Estimated subtree cost 20.6294 |
Medium-cost query under the provided scale guidance. |
| Optimization | StatementOptmEarlyAbortReason="TimeOut" |
The optimizer stopped exploration early; simpler query shape can produce a better plan. |
| Join/aggregate style | 7 hash operators, 1 sort, no parallel operators | Heavy hash/sort work suggests set inflation and limited supporting indexes. |
| Scans | Clustered scans on Posts, Comments, Votes, Users, Badges |
Broad scans dominate this plan despite selective predicates. |
| Implicit conversions | CONVERT_IMPLICIT(int,[Expr1108],0) and CONVERT_IMPLICIT(int,[Expr1111],0) |
These are aggregate output casts from COUNT_BIG to int, not predicate-side conversion issues. |
| Statistics | Referenced stats recently updated; modification counts are 0 | No immediate evidence of stale stats causing the plan. |
Detailed prioritized recommendations
Priority 1: Fix repeated Comments scans and the spool-backed EXISTS
Finding: The query pays for Comments twice: once for the comment count aggregate and again for the EXISTS predicate. The second access is especially expensive because SQL Server builds an Eager Spool from a full clustered scan.
- Evidence 1:
Index Spool (Eager Spool), estimated subtree cost15.0741, driven byClustered Index ScanonCommentswithEstimatedRowsRead=351,440. - Evidence 2: plan-level missing index on
Comments(PostId)withImpact=40.0023%; DMV also shows missing index onComments(PostId)withTotal Seeks+Scans=276.
Recommendation: Create a covering index on Comments(PostId) INCLUDE (Id, UserId). This single index supports both COUNT(c.Id) and EXISTS (... WHERE c.PostId = rp.PostId AND c.UserId IS NOT NULL).
| Index | estimatedStorageBytes | expectedReadBenefitPercent | expectedWriteOverheadPercent | maintenanceNotes | Risk assessment |
|---|---|---|---|---|---|
IX_Comments_PostId_Includes |
8388608 | 55 | 7 | Small-to-moderate NC index on a 351k-row table; PAGE compression and ONLINE build are supported. | Low-to-moderate write overhead. Safe for read-heavy workloads; monitor insert/update cost on Comments. |
Before/after query shape example: current logic effectively checks ... OR EXISTS (SELECT 1 FROM Comments c WHERE c.PostId = rp.PostId AND c.UserId IS NOT NULL); after the index, the same logic can stay unchanged but should become seekable.
Confidence: High
Priority 2: Replace the broad Votes scan with a filtered index
Finding: The query only needs bounty votes (VoteTypeId = 8) but scans the clustered index for all votes.
- Evidence 1:
Clustered Index ScanonVotesreads926,084rows and estimates only1,478qualifying rows. - Evidence 2: plan missing index on
VotesforVoteTypeIdwith includesPostId, BountyAmount; DMV also has recommendations forVotes(PostId, VoteTypeId)andVotes(VoteTypeId).
Recommendation: Prefer a filtered index over the generic missing-index script because the predicate is constant and extremely selective. Use Votes(PostId) INCLUDE (BountyAmount) WHERE VoteTypeId = 8.
| Index | estimatedStorageBytes | expectedReadBenefitPercent | expectedWriteOverheadPercent | maintenanceNotes | Risk assessment |
|---|---|---|---|---|---|
IX_Votes_PostId_Bounty_VoteType8 |
262144 | 45 | 1 | Very small filtered index; should be cheap to maintain and highly targeted for this query. | Very low risk. Best benefit-to-overhead ratio of all index options here. |
Before/after query shape example: current join condition LEFT JOIN Votes v ON p.Id = v.PostId AND v.VoteTypeId = 8 is already good; the missing piece is physical support.
Confidence: High
Priority 3: Rewrite UserReputation to avoid row multiplication and COUNT(DISTINCT)
Finding: The current UserReputation CTE joins Users to both Posts and Badges before grouping. For users with multiple posts and badges, this multiplies rows and forces a distinct aggregate.
- Evidence 1:
Hash Match (Aggregate)computesCOUNT(DISTINCT [Posts].[Id])andSUM([Expr1014]). - Evidence 2: the plan scans
Users(EstimatedRowsRead=267,193),Postsagain (EstimatedRowsRead=246,672), andBadges(EstimatedRowsRead=439,352) inside this branch.
Recommendation: Aggregate Posts by OwnerUserId first, aggregate Badges by UserId first, then join those smaller grouped sets to Users. This removes the need for COUNT(DISTINCT) and reduces hash input cardinality.
Before: Users LEFT JOIN Posts LEFT JOIN Badges GROUP BY Users.Id, Users.Reputation
After: Users LEFT JOIN (SELECT OwnerUserId, COUNT(*) ... FROM Posts GROUP BY OwnerUserId) LEFT JOIN (SELECT UserId, SUM(Class) ... FROM Badges GROUP BY UserId)
Additional note: ROW_NUMBER() values in both CTEs are currently unused in the final select/filter. Removing them simplifies the plan and may reduce sort requirements.
Confidence: High
Priority 4: Reduce Posts scanning and row-store sort work
Finding: The RankedPosts branch filters on CreationDate but currently scans the clustered index. A date-oriented covering index can reduce rows flowing into the aggregate and join pipeline.
- Evidence 1: the plan shows
Clustered Index ScanonPostswith predicateCreationDate >= '2023-10-01 12:34:56.000', reading246,672rows to estimate8,351.92. - Evidence 2: DMV missing index on
PostsforCreationDateincludingOwnerUserId, and another forOwnerUserId, CreationDate.
Recommendation: If the query remains scan-heavy after the rewrite, add Posts(CreationDate DESC, OwnerUserId) INCLUDE (Id, Title). This is more aligned to the selective date predicate than the plan’s generic OwnerUserId-only suggestion.
| Index | estimatedStorageBytes | expectedReadBenefitPercent | expectedWriteOverheadPercent | maintenanceNotes | Risk assessment |
|---|---|---|---|---|---|
IX_Posts_CreationDate_OwnerUserId |
62914560 | 22 | 10 | Largest proposed index. Use PAGE compression and ONLINE build. Reassess after query rewrite before keeping it permanently. | Moderate risk due to size and maintenance cost. Worth adding only if post-rewrite testing still shows significant Posts scan I/O. |
IX_Posts_OwnerUserId_Id |
25165824 | 18 | 6 | Useful mainly for the grouped post-count branch in the rewrite. | Low-to-moderate risk. Consider only if the rewrite’s post aggregation still scans excessively and the date-oriented index is not chosen. |
Pruning guidance: Do not create both Posts indexes blindly. Start with the rewrite; if an index is still needed, prefer IX_Posts_CreationDate_OwnerUserId for this exact query. Create IX_Posts_OwnerUserId_Id only if broader workload evidence justifies it.
Confidence: Medium
Statistics recommendations
- No urgent stats problem is visible. Referenced stats show recent updates with
ModificationCount=0andSamplingPercent=100. - After creating indexes, update statistics on the affected tables/indexes. This helps the optimizer immediately cost the new access paths.
- Keep Auto Create / Auto Update Statistics enabled. They are already enabled and appropriate here.
- Do not enable async stats solely for this query. There is no evidence of compile blocking; the issue is more about plan shape and missing access paths.
Evidence: optimizer stats usage lists current stats on Posts, Comments, Votes, Badges, and Users; all shown modification counts are 0.
Confidence: High
Table-specific insights
| Table | Insight | Evidence |
|---|---|---|
dbo.Comments |
Best immediate tuning target. Moderate size, scan-heavy, and directly involved in both aggregation and existence testing. | 78.1 MB; usage stats show Scans=826, Seeks=0. Cardinality for PostId is strong enough to support a selective lookup path. |
dbo.Votes |
Filtered access is ideal because VoteTypeId=8 appears very selective. |
Plan reads 926,084 rows for estimated 1,478 matches. VoteTypeId has only ~13 distinct values overall, but the filtered predicate is still highly selective in this data. |
dbo.Posts |
Large row width likely makes clustered scans more expensive than row count alone suggests. | 399 MB for 740,016 rows; columns like Body and Title increase row size. Usage stats show Scans=2418 on clustered PK. |
dbo.Badges |
A grouped aggregate by UserId will benefit from a narrow index if this pattern is common. |
DMV missing index on Badges(UserId) INCLUDE (Class) with high impact; current plan scans clustered index with EstimatedRowsRead=439,352. |
dbo.Users |
Users scan is acceptable after rowset reduction, but currently it participates too early. | 44.8 MB clustered index with Scans=1707. The main issue is not lack of an index on Users.Id but query shape. |
Optional broader-workload index: if badge-by-user aggregation is common beyond this one query, a narrow index on Badges(UserId) INCLUDE (Class) is defensible. It is not in the top 3 for this query alone, so it is not prioritized first.
Before/after plan comparison
Validation guidance
- Validate each change incrementally, not all at once.
- Capture before/after metrics using
SET STATISTICS IO ONandSET STATISTICS TIME ON. - Compare logical reads on
Comments,Votes, andPostsspecifically. - After each index creation, inspect the new estimated or actual plan for elimination of:
Commentsclustered scansIndex Spool (Eager Spool)- full
Votesclustered scan forVoteTypeId = 8 COUNT(DISTINCT)aggregate in the user branch
Scripts
Create the highest-priority Comments covering index (implements Priority 1)
USE [SQLStorm];
GO
CREATE INDEX [IX_Comments_PostId_Includes]
ON [dbo].[Comments] ([PostId])
INCLUDE ([Id], [UserId])
WITH (ONLINE = ON, DATA_COMPRESSION = PAGE, SORT_IN_TEMPDB = ON);
GO
Create the filtered Votes index for bounty lookups (implements Priority 2)
USE [SQLStorm];
GO
CREATE INDEX [IX_Votes_PostId_Bounty_VoteType8]
ON [dbo].[Votes] ([PostId])
INCLUDE ([BountyAmount])
WHERE [VoteTypeId] = 8
WITH (ONLINE = ON, DATA_COMPRESSION = PAGE, SORT_IN_TEMPDB = ON);
GO
Create the optional Posts date-oriented covering index (implements Priority 4)
USE [SQLStorm];
GO
CREATE INDEX [IX_Posts_CreationDate_OwnerUserId]
ON [dbo].[Posts] ([CreationDate] DESC, [OwnerUserId])
INCLUDE ([Id], [Title])
WITH (ONLINE = ON, DATA_COMPRESSION = PAGE, SORT_IN_TEMPDB = ON);
GO
Create the optional Posts owner aggregation index if broader workload testing justifies it (implements Priority 4 alternative)
USE [SQLStorm];
GO
CREATE INDEX [IX_Posts_OwnerUserId_Id]
ON [dbo].[Posts] ([OwnerUserId], [Id])
WITH (ONLINE = ON, DATA_COMPRESSION = PAGE, SORT_IN_TEMPDB = ON);
GO
Create the optional Badges-by-user index if this aggregation pattern is common (supports Priority 3 broader workload)
USE [SQLStorm];
GO
CREATE INDEX [IX_Badges_UserId_Class]
ON [dbo].[Badges] ([UserId])
INCLUDE ([Class])
WITH (ONLINE = ON, DATA_COMPRESSION = PAGE, SORT_IN_TEMPDB = ON);
GO
Run the rewritten query shape for testing (implements Priority 3)
USE [SQLStorm];
GO
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
WITH PostAgg AS
(
SELECT
p.OwnerUserId,
COUNT_BIG(*) AS PostCount
FROM dbo.Posts AS p
GROUP BY p.OwnerUserId
),
BadgeAgg AS
(
SELECT
b.UserId,
SUM(ISNULL(b.Class, 0)) AS TotalBadges
FROM dbo.Badges AS b
GROUP BY b.UserId
),
ActiveUsers AS
(
SELECT
u.Id AS UserId,
u.Reputation,
CONVERT(int, pa.PostCount) AS PostCount,
ISNULL(ba.TotalBadges, 0) AS TotalBadges
FROM dbo.Users AS u
INNER JOIN PostAgg AS pa
ON pa.OwnerUserId = u.Id
LEFT JOIN BadgeAgg AS ba
ON ba.UserId = u.Id
WHERE pa.PostCount > 5
),
RecentPosts AS
(
SELECT
p.Id AS PostId,
p.Title,
p.CreationDate,
p.OwnerUserId
FROM dbo.Posts AS p
WHERE p.CreationDate >= DATEADD(year, -1, CAST('2024-10-01T12:34:56' AS datetime))
),
CommentAgg AS
(
SELECT
c.PostId,
COUNT(c.Id) AS CommentCount,
MAX(CASE WHEN c.UserId IS NOT NULL THEN 1 ELSE 0 END) AS HasUserComment
FROM dbo.Comments AS c
GROUP BY c.PostId
),
BountyAgg AS
(
SELECT
v.PostId,
SUM(v.BountyAmount) AS TotalBounty
FROM dbo.Votes AS v
WHERE v.VoteTypeId = 8
GROUP BY v.PostId
)
SELECT
rp.PostId,
rp.Title,
rp.CreationDate,
ua.UserId,
ua.Reputation,
ISNULL(ca.CommentCount, 0) AS CommentCount,
ba.TotalBounty,
ua.PostCount,
ua.TotalBadges
FROM RecentPosts AS rp
INNER JOIN ActiveUsers AS ua
ON ua.UserId = rp.OwnerUserId
LEFT JOIN CommentAgg AS ca
ON ca.PostId = rp.PostId
LEFT JOIN BountyAgg AS ba
ON ba.PostId = rp.PostId
WHERE ISNULL(ba.TotalBounty, 0) > 0
OR ISNULL(ca.HasUserComment, 0) = 1
ORDER BY
ua.Reputation DESC,
rp.CreationDate DESC
OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY;
GO
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
GO
Refresh statistics on affected tables after index changes (implements Statistics recommendations)
USE [SQLStorm];
GO
UPDATE STATISTICS dbo.Comments WITH FULLSCAN;
UPDATE STATISTICS dbo.Votes WITH FULLSCAN;
UPDATE STATISTICS dbo.Posts WITH FULLSCAN;
UPDATE STATISTICS dbo.Badges WITH FULLSCAN;
GO