Query Tuner – RockyPC / SQLStorm / 0x932168333A069240 – 2026-05-31

Tuning goal: Query TunerEstimated cost: 20.6294 (Medium)Confidence: Medium-High

Server: RockyPC
Database: SQLStorm
Version: SQL Server 2022 (16.0.1180.1), Developer Edition / Enterprise engine
Query hash: 0x932168333A069240
Plan hash: 0xC5C3D34B87977918
Optimizer note: TimeOut

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

  1. Implement the Comments and Votes indexes first. These target the largest avoidable scans and the spool-heavy EXISTS branch.
  2. Rewrite the query second. The rewrite removes row multiplication and should reduce hashes, sorts, and compilation complexity.
  3. 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 cost 15.0741, driven by Clustered Index Scan on Comments with EstimatedRowsRead=351,440.
  • Evidence 2: plan-level missing index on Comments(PostId) with Impact=40.0023%; DMV also shows missing index on Comments(PostId) with Total 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 Scan on Votes reads 926,084 rows and estimates only 1,478 qualifying rows.
  • Evidence 2: plan missing index on Votes for VoteTypeId with includes PostId, BountyAmount; DMV also has recommendations for Votes(PostId, VoteTypeId) and Votes(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) computes COUNT(DISTINCT [Posts].[Id]) and SUM([Expr1014]).
  • Evidence 2: the plan scans Users (EstimatedRowsRead=267,193), Posts again (EstimatedRowsRead=246,672), and Badges (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 Scan on Posts with predicate CreationDate >= '2023-10-01 12:34:56.000', reading 246,672 rows to estimate 8,351.92.
  • Evidence 2: DMV missing index on Posts for CreationDate including OwnerUserId, and another for OwnerUserId, 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=0 and SamplingPercent=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

Execution plan comparison before and after recommended changes Diagram showing the original plan with clustered scans, spool, hash aggregates, and sort, versus a simplified optimized plan with targeted seeks and smaller aggregates. Before After Clustered Scan: Votes Clustered Scan: Comments Hash Aggregate Eager Spool for EXISTS COUNT(DISTINCT) Aggregate Sort + Top Seek: filtered Votes index Seek: Comments(PostId) Small aggregate Semi-join seek Pre-aggregated user metrics join Sort + Top 100
Figure: The recommended changes mainly replace broad scans and a spool with targeted seeks and smaller grouped inputs. The final sort remains, but it should process many fewer rows.

Validation guidance

  • Validate each change incrementally, not all at once.
  • Capture before/after metrics using SET STATISTICS IO ON and SET STATISTICS TIME ON.
  • Compare logical reads on Comments, Votes, and Posts specifically.
  • After each index creation, inspect the new estimated or actual plan for elimination of:
    • Comments clustered scans
    • Index Spool (Eager Spool)
    • full Votes clustered scan for VoteTypeId = 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