Index Tuning Report – RockyPC / SQLStorm – 2026-05-31

Tuning goal: Index Tuning

Server: RockyPC
Database: SQLStorm
Server version: Microsoft SQL Server 2022 (RTM-GDR) 16.0.1180.1, Enterprise-capable platform

Executive summary

Highest-value index targets
Votes, PostHistory, Badges, Comments, Posts
Primary workload issue
Repeated large scans on clustered PKs
Write overhead risk
Low
Immediate maintenance need
No rebuilds indicated

The workload is dominated by long-running analytical-style queries that repeatedly join and aggregate on Posts.OwnerUserId, Votes.PostId, Votes.UserId, Badges.UserId, Comments.PostId, and PostHistory.PostId. Existing access paths are almost entirely clustered PK scans on Id.

Because index usage shows essentially no update pressure in the captured period and page density is already excellent across current indexes, the best return is to add a small set of targeted nonclustered indexes rather than rebuild existing ones. Columnstore is not recommended at this time because no table in scope exceeds the stated 1,000,000-row threshold, and the heaviest pain points are still better addressed first with selective rowstore indexes and query rewrites.

Priority Recommendation Expected Benefit Confidence
1 Create Votes(PostId, VoteTypeId) covering index Reduce large scans for vote aggregation by post in many top-cost queries High
2 Create Votes(UserId) covering index Improve joins and aggregates by voter/user High
3 Create PostHistory(PostId, PostHistoryTypeId, CreationDate) covering index Reduce 820 MB table scans for close/open/history lookups High
4 Create consolidated Badges(UserId) covering index Serve multiple missing-index requests with one index High
5 Create Comments(PostId) covering index Speed comment counts and joins by post High
6 Create Posts(OwnerUserId, CreationDate) covering index Improve grouping/ranking by owner and recent-post access Medium-High
7 Create Users(Reputation DESC) covering index Support reputation filters/sorts; lower impact than items above Medium

Top priorities

  1. Index vote aggregation paths first. The most repeated expensive joins are Posts.Id = Votes.PostId and Users.Id = Votes.UserId, often with aggregation by VoteTypeId and occasional use of BountyAmount. The clustered PK on Votes(Id) is not aligned to the workload.
  2. Index PostHistory for per-post event lookups. PostHistory is the largest table in scope by size (820.3 MB), and the current workload repeatedly scans it for close/open history and latest history rows by post.
  3. Consolidate the three Badge missing-index requests into one index. All missing-index suggestions on Badges share the same leading key UserId; one properly covered index is enough.
  4. Support Comments by PostId and Posts by OwnerUserId. These are common join and grouping columns in plan cache and Query Store, but the current design forces repeated scans.
  5. Prefer code fixes for tag searches. The most expensive queries also use Posts.Tags LIKE '%' + TagName + '%', which no normal b-tree index can optimize. Indexes help surrounding joins, but the tag-search pattern itself needs redesign.

Detailed prioritized recommendations

1. Create a covering index on dbo.Votes for post-based vote aggregation

Recommendation: Create IX_Votes_PostId_VoteTypeId on (PostId, VoteTypeId) with includes (UserId, BountyAmount, CreationDate).

  • Why: Missing index signal on Votes(PostId) INCLUDE (VoteTypeId) has the highest frequency on the table (155 seeks/scans requested). Query texts repeatedly aggregate votes per post, often split by vote type.
  • Evidence: Clustered PK on Votes(Id) shows 1,143 scans, 0 seeks.
  • Cardinality fit: PostId has estimated 242,634 distinct values in 926,084 rows; selective enough for a leading key. VoteTypeId is low cardinality alone, but highly effective as a second key for grouped vote lookups.
  • Workload examples:
    • Query hash 2C1078E11002C34F: LEFT JOIN Votes V ON P.Id = V.PostId with vote-type aggregation.
    • Query hash 558D53CC7558B81F: grouped vote counts by PostId and VoteTypeId.
    • Query hash DF924AD85121AF91: SELECT PostId, COUNT(*) FROM Votes GROUP BY PostId.
  • Overhead assessment: Low risk in this captured workload because user_updates = 0 and estimated storage is modest relative to benefit.

Confidence: 0.96

2. Create a covering index on dbo.Votes for user-based vote access

Recommendation: Create IX_Votes_UserId on (UserId) with includes (VoteTypeId, PostId, BountyAmount, CreationDate).

  • Why: Missing index requests on UserId appear multiple times, including coverage for VoteTypeId and BountyAmount.
  • Evidence: Queries repeatedly join Users.Id = Votes.UserId and aggregate votes by user, especially for upvote/downvote and bounty summaries.
  • Cardinality note: The reported single-column UserId distinct count appears unusually low; this suggests skew and/or many nulls, which makes fresh statistics advisable, but the query pattern still strongly justifies the index.
  • Workload examples:
    • Query hash 6013F73BCD0BD360: LEFT JOIN Votes v ON u.Id = v.UserId.
    • Query hash 4DE12E1909540955: aggregates SUM(CASE WHEN v.VoteTypeId = ...) by user.
    • Query hash FE52206CED094538: user voting stats by Votes.UserId.
  • Overhead assessment: Low in current telemetry; still keep separate from the post-based index because the two access paths serve different join orders.

Confidence: 0.94

3. Create a composite index on dbo.PostHistory for per-post history retrieval

Recommendation: Create IX_PostHistory_PostId_Type_CreateDate on (PostId, PostHistoryTypeId, CreationDate DESC) with includes (UserId, Comment).

  • Why: Missing-index signals on PostHistory repeatedly point to PostId and PostHistoryTypeId. The current clustered index on Id causes scans of an 820.3 MB object.
  • Evidence: PostHistory clustered PK shows 464 scans, 0 seeks.
  • Cardinality fit: PostId has about 246,673 distinct values; PostHistoryTypeId alone is low-cardinality, but as a second key it becomes useful for targeted event access.
  • Workload examples:
    • Query hash 89E1FC9F9BE1B457: LEFT JOIN PostHistory ph ON p.Id = ph.PostId and counts of PostHistoryTypeId = 10.
    • Query hash 1DDFA93818604C79: close reason lookup where PH.PostHistoryTypeId IN (10, 11).
    • Query hash 4DE12E1909540955: latest history row per post via ROW_NUMBER() OVER (PARTITION BY ph.PostId ORDER BY ph.CreationDate DESC).
  • Filtered alternative considered: A filtered index for PostHistoryTypeId IN (10,11) would be excellent for close/open history, but the broader composite index is safer because the workload also references general history patterns.

Confidence: 0.95

4. Consolidate Badge recommendations into one nonclustered index

Recommendation: Create IX_Badges_UserId on (UserId) with includes (Class, Name, [Date]).

  • Why: Three missing-index recommendations all target Badges.UserId with varying include lists. One consolidated index avoids creating multiple overlapping structures.
  • Evidence: Badges clustered PK shows 628 scans, 0 seeks.
  • Cardinality fit: UserId has ~154,016 distinct values in 439,352 rows; good selectivity. Class is low-cardinality and belongs as an included column, not a leading key.
  • Workload examples:
    • Query hash 6DA5B718F2D3A8A5: badge counts by user and class.
    • Query hash 2481A8EBEE0C1B29: LEFT JOIN Badges B ON U.Id = B.UserId.
    • Query hash 2E2513AA5AB15E6E: COUNT(DISTINCT b.Id) grouped by user.
  • Overhead assessment: Very favorable; no captured writes and modest estimated size.

Confidence: 0.97

5. Create a nonclustered index on dbo.Comments for post joins and comment counts

Recommendation: Create IX_Comments_PostId on (PostId) with includes (UserId, CreationDate).

  • Why: Missing-index request on Comments(PostId) has the highest access count in the list for that table (261). Numerous queries count or join comments per post.
  • Evidence: Comments clustered PK shows 769 scans, 0 seeks.
  • Cardinality fit: PostId has ~122,541 distinct values in 351,440 rows; strong candidate for a leading key.
  • Workload examples:
    • Query hash 255E97B49AAD8F59: LEFT JOIN Comments c ON p.Id = c.PostId.
    • Query hash C4335EFDAF4564AB: grouped comment counts by post.
    • Query hash 9745751A14565D78: recent post calculations with comment joins.

Confidence: 0.95

6. Create a nonclustered index on dbo.Posts for owner-based grouping and recent-post ranking

Recommendation: Create IX_Posts_OwnerUserId_CreationDate on (OwnerUserId, CreationDate DESC) with includes (Id, PostTypeId, Score, ViewCount, AcceptedAnswerId, AnswerCount, CommentCount, Title).

  • Why: Query Store is dominated by CTEs and window functions that group or rank posts per owner, frequently filtered by recent CreationDate. Missing overlap also identifies Posts(OwnerUserId) as a candidate.
  • Evidence: Posts clustered PK shows 2,271 scans, only 216 seeks.
  • Cardinality fit: OwnerUserId has ~65,118 distinct values in 246,672 rows, a strong grouping key.
  • Workload examples:
    • Query hash EC28C61EF14FD8A3: LEFT JOIN Posts P ON U.Id = P.OwnerUserId.
    • Query hash 6013F73BCD0BD360: ROW_NUMBER() OVER (PARTITION BY p.OwnerUserId ORDER BY p.Score DESC) with recent date filter.
    • Query hash 558D53CC7558B81F: ROW_NUMBER() OVER (PARTITION BY p.OwnerUserId ORDER BY p.CreationDate DESC).
  • Tradeoff: This is the widest new index proposed. It is still justified by the scan-heavy read-mostly workload, but should be implemented after the Votes and PostHistory indexes.

Confidence: 0.88

7. Add a lower-priority index on dbo.Users for reputation-driven filtering and ordering

Recommendation: Create IX_Users_Reputation on (Reputation DESC) with includes (Id, DisplayName, CreationDate).

  • Why: Many query texts filter on u.Reputation > 100 or > 1000 and rank by reputation. Missing-index impact score is modest, so this is not a top-tier change.
  • Evidence: Users already has some seeks on the clustered PK, so this is additive rather than foundational.
  • Cardinality fit: Reputation has ~2,443 distinct values over 267,193 rows; selective enough for range predicates, but much less compelling than the join keys above.
  • Workload examples:
    • Query hash 70286E22083EFFAD: WHERE u.Reputation > 1000.
    • Query hash 54,576-second QS entry: WHERE U.Reputation > 100.
    • Query hash 2E2513AA5AB15E6E: ranking by reputation.

Confidence: 0.73

8. Refresh statistics on the main join tables after index deployment

  • Why: Cardinality on Votes.UserId appears suspicious relative to workload shape, and the top queries are highly aggregation-heavy.
  • Recommendation: Run targeted UPDATE STATISTICS ... WITH FULLSCAN on Posts, Votes, PostHistory, Comments, Badges, and Users after the new indexes are created.
  • Database settings: AUTO_CREATE_STATISTICS = ON and AUTO_UPDATE_STATISTICS = ON are correct; no setting change is recommended.

Confidence: 0.86

Query patterns driving recommendations

Pattern Observed Shape Primary Fix
Votes by post LEFT JOIN Votes V ON P.Id = V.PostId with SUM/COUNT CASE WHEN VoteTypeId ... IX_Votes_PostId_VoteTypeId
Votes by user LEFT JOIN Votes v ON u.Id = v.UserId IX_Votes_UserId
Badges by user LEFT JOIN Badges b ON u.Id = b.UserId, grouped by class/name IX_Badges_UserId
Comments by post LEFT JOIN Comments c ON p.Id = c.PostId, COUNT(c.Id) IX_Comments_PostId
Post history by post and type JOIN/LEFT JOIN PostHistory ph ON p.Id = ph.PostId, filters on type 10/11 IX_PostHistory_PostId_Type_CreateDate
Posts by owner GROUP BY OwnerUserId, recent-date filters, ranking within owner partitions IX_Posts_OwnerUserId_CreationDate
Tag search Posts.Tags LIKE '%' + T.TagName + '%' Code redesign; standard index will not solve this

The biggest end-to-end savings should come from reducing repeated full/large-range scans before aggregation rather than from minor operator tuning.

Operational findings

  • No page-density issue found. All reported indexes have excellent average page density (98% to 99.85%) and negligible fragmentation.
  • No index rebuild recommendation. There is no evidence of low page utilization below the ~75% threshold, nor operational hotspots such as latch or lock waits on indexes in scope.
  • No drop recommendations. Only clustered PKs are present in scope for major tables; there are no redundant nonclustered indexes to remove.
  • No heap issue. No large heap tables detected.
  • Version note. This instance is SQL Server 2022 RTM-GDR. While not an immediate index action, later CUs often contain optimizer and Query Store fixes. A controlled patch review is advisable after index work is validated.

Automatic tuning

  • FORCE_LAST_GOOD_PLAN: Desired state ON, actual state ON.
  • Recommendation: No change required. Current configuration is correct and aligns with safe defaults.

Forced plan failures

No forced plan failures detected.

No remediation scripts are required for Query Store forced-plan issues in this run.

Code changes

  1. Normalize tag searching. The repeated pattern Posts.Tags LIKE '%' + TagName + '%' is non-SARGable and forces scans. Replace the denormalized tag string search with a junction table such as PostTags(PostId, TagId) and index it on (TagId, PostId) and optionally (PostId, TagId).
  2. Pre-aggregate before joining where possible. Several top-cost queries join wide tables first and aggregate later. Prefer patterns like SELECT PostId, COUNT(*) ... GROUP BY PostId in a small derived set, then join back to Posts or Users.
  3. Avoid unnecessary COUNT(DISTINCT ...) after fanout joins. Many queries combine Users, Posts, Votes, Comments, and Badges in one layer, which multiplies row counts and inflates memory grants. Aggregate each child table separately by its join key first.
  4. Review the spilling query. Query hash 558D53CC7558B81F spilled 38,164 pages. Indexing will help, but the plan shape also suggests row explosion from joining aggregated and non-aggregated sets together.

Deferred / not recommended

  • No clustered index or primary key changes. There is no evidence justifying PK replacement, so no FK drop/recreate work is recommended.
  • No columnstore recommendation. Largest table in scope is PostHistory at 847,593 rows, below the stated 1,000,000-row threshold.
  • No filtered index on low-cardinality Badges.Class alone. Too low-cardinality to be a useful leading key.
  • No maintenance-only rebuild scripts. Fragmentation and density do not justify them.
  • No system database actions. None recommended.

Scripts

Create Recommendation 1: Votes index for post-based vote aggregation

USE [SQLStorm];
GO
IF NOT EXISTS
(
    SELECT 1
    FROM sys.indexes
    WHERE name = N'IX_Votes_PostId_VoteTypeId'
      AND object_id = OBJECT_ID(N'dbo.Votes')
)
BEGIN
    CREATE NONCLUSTERED INDEX [IX_Votes_PostId_VoteTypeId]
    ON [dbo].[Votes] ([PostId] ASC, [VoteTypeId] ASC)
    INCLUDE ([UserId], [BountyAmount], [CreationDate])
    WITH
    (
        DATA_COMPRESSION = PAGE,
        SORT_IN_TEMPDB = ON,
        ONLINE = ON,
        FILLFACTOR = 100
    );
END
GO

Create Recommendation 2: Votes index for user-based vote access

USE [SQLStorm];
GO
IF NOT EXISTS
(
    SELECT 1
    FROM sys.indexes
    WHERE name = N'IX_Votes_UserId'
      AND object_id = OBJECT_ID(N'dbo.Votes')
)
BEGIN
    CREATE NONCLUSTERED INDEX [IX_Votes_UserId]
    ON [dbo].[Votes] ([UserId] ASC)
    INCLUDE ([VoteTypeId], [PostId], [BountyAmount], [CreationDate])
    WITH
    (
        DATA_COMPRESSION = PAGE,
        SORT_IN_TEMPDB = ON,
        ONLINE = ON,
        FILLFACTOR = 100
    );
END
GO

Create Recommendation 3: PostHistory index for per-post history and latest-history lookups

USE [SQLStorm];
GO
IF NOT EXISTS
(
    SELECT 1
    FROM sys.indexes
    WHERE name = N'IX_PostHistory_PostId_Type_CreateDate'
      AND object_id = OBJECT_ID(N'dbo.PostHistory')
)
BEGIN
    CREATE NONCLUSTERED INDEX [IX_PostHistory_PostId_Type_CreateDate]
    ON [dbo].[PostHistory] ([PostId] ASC, [PostHistoryTypeId] ASC, [CreationDate] DESC)
    INCLUDE ([UserId], [Comment])
    WITH
    (
        DATA_COMPRESSION = PAGE,
        SORT_IN_TEMPDB = ON,
        ONLINE = ON,
        FILLFACTOR = 100
    );
END
GO

Create Recommendation 4: Consolidated Badges index covering UserId access patterns

USE [SQLStorm];
GO
IF NOT EXISTS
(
    SELECT 1
    FROM sys.indexes
    WHERE name = N'IX_Badges_UserId'
      AND object_id = OBJECT_ID(N'dbo.Badges')
)
BEGIN
    CREATE NONCLUSTERED INDEX [IX_Badges_UserId]
    ON [dbo].[Badges] ([UserId] ASC)
    INCLUDE ([Class], [Name], [Date])
    WITH
    (
        DATA_COMPRESSION = PAGE,
        SORT_IN_TEMPDB = ON,
        ONLINE = ON,
        FILLFACTOR = 100
    );
END
GO

Create Recommendation 5: Comments index for post joins and comment counts

USE [SQLStorm];
GO
IF NOT EXISTS
(
    SELECT 1
    FROM sys.indexes
    WHERE name = N'IX_Comments_PostId'
      AND object_id = OBJECT_ID(N'dbo.Comments')
)
BEGIN
    CREATE NONCLUSTERED INDEX [IX_Comments_PostId]
    ON [dbo].[Comments] ([PostId] ASC)
    INCLUDE ([UserId], [CreationDate])
    WITH
    (
        DATA_COMPRESSION = PAGE,
        SORT_IN_TEMPDB = ON,
        ONLINE = ON,
        FILLFACTOR = 100
    );
END
GO

Create Recommendation 6: Posts index for owner-based grouping and recent-post access

USE [SQLStorm];
GO
IF NOT EXISTS
(
    SELECT 1
    FROM sys.indexes
    WHERE name = N'IX_Posts_OwnerUserId_CreationDate'
      AND object_id = OBJECT_ID(N'dbo.Posts')
)
BEGIN
    CREATE NONCLUSTERED INDEX [IX_Posts_OwnerUserId_CreationDate]
    ON [dbo].[Posts] ([OwnerUserId] ASC, [CreationDate] DESC)
    INCLUDE ([Id], [PostTypeId], [Score], [ViewCount], [AcceptedAnswerId], [AnswerCount], [CommentCount], [Title])
    WITH
    (
        DATA_COMPRESSION = PAGE,
        SORT_IN_TEMPDB = ON,
        ONLINE = ON,
        FILLFACTOR = 100
    );
END
GO

Create Recommendation 7: Users index for reputation filters and ordering

USE [SQLStorm];
GO
IF NOT EXISTS
(
    SELECT 1
    FROM sys.indexes
    WHERE name = N'IX_Users_Reputation'
      AND object_id = OBJECT_ID(N'dbo.Users')
)
BEGIN
    CREATE NONCLUSTERED INDEX [IX_Users_Reputation]
    ON [dbo].[Users] ([Reputation] DESC)
    INCLUDE ([Id], [DisplayName], [CreationDate])
    WITH
    (
        DATA_COMPRESSION = PAGE,
        SORT_IN_TEMPDB = ON,
        ONLINE = ON,
        FILLFACTOR = 100
    );
END
GO

Implement Recommendation 8: Refresh key table statistics after index deployment

USE [SQLStorm];
GO
UPDATE STATISTICS [dbo].[Votes] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[PostHistory] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[Badges] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[Comments] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[Posts] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[Users] WITH FULLSCAN;
GO