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
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
-
Index vote aggregation paths first.
The most repeated expensive joins are
Posts.Id = Votes.PostIdandUsers.Id = Votes.UserId, often with aggregation byVoteTypeIdand occasional use ofBountyAmount. The clustered PK onVotes(Id)is not aligned to the workload. -
Index PostHistory for per-post event lookups.
PostHistoryis 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. -
Consolidate the three Badge missing-index requests into one index.
All missing-index suggestions on
Badgesshare the same leading keyUserId; one properly covered index is enough. - 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.
-
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)shows1,143 scans,0 seeks. - Cardinality fit:
PostIdhas estimated 242,634 distinct values in 926,084 rows; selective enough for a leading key.VoteTypeIdis 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.PostIdwith vote-type aggregation. - Query hash
558D53CC7558B81F: grouped vote counts byPostIdandVoteTypeId. - Query hash
DF924AD85121AF91:SELECT PostId, COUNT(*) FROM Votes GROUP BY PostId.
- Query hash
- Overhead assessment: Low risk in this captured workload because
user_updates = 0and 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
UserIdappear multiple times, including coverage forVoteTypeIdandBountyAmount. - Evidence: Queries repeatedly join
Users.Id = Votes.UserIdand aggregate votes by user, especially for upvote/downvote and bounty summaries. - Cardinality note: The reported single-column
UserIddistinct 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: aggregatesSUM(CASE WHEN v.VoteTypeId = ...)by user. - Query hash
FE52206CED094538: user voting stats byVotes.UserId.
- Query hash
- 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
PostHistoryrepeatedly point toPostIdandPostHistoryTypeId. The current clustered index onIdcauses scans of an 820.3 MB object. - Evidence:
PostHistoryclustered PK shows464 scans,0 seeks. - Cardinality fit:
PostIdhas about 246,673 distinct values;PostHistoryTypeIdalone 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.PostIdand counts ofPostHistoryTypeId = 10. - Query hash
1DDFA93818604C79: close reason lookup wherePH.PostHistoryTypeId IN (10, 11). - Query hash
4DE12E1909540955: latest history row per post viaROW_NUMBER() OVER (PARTITION BY ph.PostId ORDER BY ph.CreationDate DESC).
- Query hash
- 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.UserIdwith varying include lists. One consolidated index avoids creating multiple overlapping structures. - Evidence:
Badgesclustered PK shows628 scans,0 seeks. - Cardinality fit:
UserIdhas ~154,016 distinct values in 439,352 rows; good selectivity.Classis 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.
- Query hash
- 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:
Commentsclustered PK shows769 scans,0 seeks. - Cardinality fit:
PostIdhas ~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.
- Query hash
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 identifiesPosts(OwnerUserId)as a candidate. - Evidence:
Postsclustered PK shows2,271 scans, only216 seeks. - Cardinality fit:
OwnerUserIdhas ~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).
- Query hash
- 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 > 100or> 1000and rank by reputation. Missing-index impact score is modest, so this is not a top-tier change. - Evidence:
Usersalready has some seeks on the clustered PK, so this is additive rather than foundational. - Cardinality fit:
Reputationhas ~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.
- Query hash
Confidence: 0.73
8. Refresh statistics on the main join tables after index deployment
- Why: Cardinality on
Votes.UserIdappears suspicious relative to workload shape, and the top queries are highly aggregation-heavy. - Recommendation: Run targeted
UPDATE STATISTICS ... WITH FULLSCANonPosts,Votes,PostHistory,Comments,Badges, andUsersafter the new indexes are created. - Database settings:
AUTO_CREATE_STATISTICS = ONandAUTO_UPDATE_STATISTICS = ONare 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 stateON. - 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
-
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 asPostTags(PostId, TagId)and index it on(TagId, PostId)and optionally(PostId, TagId). -
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 PostIdin a small derived set, then join back toPostsorUsers. -
Avoid unnecessary
COUNT(DISTINCT ...)after fanout joins. Many queries combineUsers,Posts,Votes,Comments, andBadgesin one layer, which multiplies row counts and inflates memory grants. Aggregate each child table separately by its join key first. -
Review the spilling query.
Query hash
558D53CC7558B81Fspilled38,164pages. 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
PostHistoryat 847,593 rows, below the stated 1,000,000-row threshold. - No filtered index on low-cardinality
Badges.Classalone. 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