Query Tuner Report – RockyPC / SQLStorm – QueryHash 0x872C7773968C6AAF
Tuning goal: Query Tuner
Executive summary
CRITICAL The estimated statement cost is 202.053, which is critical under the supplied thresholds. The dominant cost is the tag-matching branch, not the final ranking.
- Address the non-sargable tag join and its repeated work. The predicate
Posts.Tags LIKE '%' + Tags.TagName + '%'forces a broad scan and a nested-loops comparison against all 1,232 tags. This branch accounts for estimated subtree cost 193.69. - Add targeted covering indexes for the two large join/aggregation paths. The plan scans 926,084 Votes, 439,352 Badges, and Posts repeatedly because only clustered primary-key indexes are available for the relevant foreign-key columns.
- Enable automatic statistics management and refresh heavily modified statistics. Auto-create and auto-update statistics are disabled. Votes statistics show 1,600,424 modifications since their last update.
- Review the query's join correctness before production use. The final join is
tp.UserId = rp.PostId, althoughRankedPostsexposes a post identifier. This is preserved in the supplied statement, but it likely produces unintended matches.
UserStats would change the original result because the original query multiplies Posts, Votes, and Badges rows before applying distinct counts. No rewritten query is supplied in this report because an unverified rewrite cannot guarantee the exact same rows and values.
Environment and scope
| Item | Observed value |
|---|---|
| Server / database | RockyPC / SQLStorm |
| Engine | SQL Server 2022, version 16.0.1200.5, RTM-GDR |
| Edition capabilities | Developer Edition; Enterprise engine features reported as available, including online operations, compression, columnstore, and IQP features |
| Estimated statement cost | 202.053 — critical |
| Estimated output | 71,174.1 rows |
| Plan characteristics | Batch mode on rowstore; one parallel branch; three Sort operators; five Hash Match operators |
| Query Store | No matching Query Store history was supplied |
| Parameters | No query parameters are present; parameter-sniffing remediation is not applicable |
Detailed plan findings
1. Non-sargable tag matching is the primary bottleneck
Evidence: Node 8 is a Nested Loops join with estimated cost 193.690. Its Posts input scans 246,672 rows after a clustered scan, while the Tags input scans 1,232 rows and is reused through a Lazy Spool with an estimated 104,581 rewinds.
The leading wildcard prevents a normal B-tree seek on Posts.Tags. An index on the existing delimited text column can narrow projected data but cannot turn this substring search into an efficient seek.
Impact: High confidence, approximately 95%. This branch dominates the 202.053 estimated statement cost.
2. Wide clustered scan of Posts is repeated for different purposes
Evidence: Node 9 reads 246,672 Posts rows with average row size 2,177 bytes and estimated cost 2.493. Node 25 separately scans all 246,672 Posts rows for the OwnerUserId join.
The clustered Posts index is 400.3 MB and has 5,374 scans, compared with 512 seeks. A narrow filtered index for question posts and a narrow OwnerUserId index can reduce I/O for these branches.
Impact: Medium to high confidence, approximately 88%. The reduction is constrained by the non-sargable tag predicate.
3. Votes and Badges are scanned because join keys are not indexed
Evidence: Node 21 scans all 926,084 Votes rows for the PostId join; Node 23 scans all 439,352 Badges rows for the UserId join. Their estimated costs are 1.247 and 0.821 respectively, with Hash Match operators above them.
Usage statistics show zero seeks for the clustered Votes and Badges indexes, while the query needs PostId and UserId access paths. The DMV reports a Votes(PostId) INCLUDE(VoteTypeId) candidate with 350 seeks/scans and 61.06% average impact.
Impact: Medium confidence, approximately 86%. These indexes are especially valuable if the query is rewritten later to safely pre-aggregate by post and user.
4. The original multiway outer join inflates intermediate rows
Evidence: Node 19 estimates 1,659,060 rows before aggregation, while the aggregate at Node 18 estimates 167,486 user groups. The plan performs distinct aggregation for Posts.Id and Badges.Id after Votes and Badges have already joined.
This is a logical and performance concern. Separate pre-aggregation would normally be preferred, but it must be validated against the original output because the current query's counts and vote sums are affected by row multiplication.
Impact: High confidence for performance risk; exact rewrite benefit is not quantified because result equivalence has not been established.
5. Sorts and memory grant require validation, but no spill is proven
Evidence: The plan contains Sort operators at Nodes 2, 7, and 14. The plan reports serial desired memory of 5,717,600 KB, but requested, granted, and maximum-used memory are all reported as 0 KB.
No spill warning, spill level, or tempdb spill counters are present in the supplied XML. Therefore, a spill must not be asserted. The large desired grant and 721-byte estimated final row width still justify checking actual execution behavior.
Impact: Medium confidence, approximately 70% for memory pressure risk and low confidence for an actual spill.
6. Statistics configuration is unsafe for a changing workload
Evidence: Auto-create statistics, auto-update statistics, and asynchronous statistics updates are all disabled. Votes statistics show modification count 1,600,424 with last updates around 2026-09-12.
Posts and Users statistics were sampled at approximately 37.7% and 58.5%, respectively. Current estimates may be adequate for this compilation, but disabled automatic maintenance creates ongoing plan-regression risk.
Impact: High confidence, approximately 93%.
7. No implicit conversion or parameter-sniffing issue is evident
Evidence: The XML shows implicit conversions of aggregate expressions to int at Node 17, not a column-to-column join conversion. The query contains literals only and no parameters.
The aggregate conversions may overflow for sufficiently large counts or sums, but they are not the primary access-path bottleneck. Parameter-sensitive plan optimization and plan guides are not applicable to this supplied text.
Impact: High confidence, approximately 90%.
Detailed prioritized recommendations
-
Priority 1 — Fix or redesign tag storage and matching.
Long-term, normalize the relationship between Posts and Tags into a bridge table, for example PostTags(PostId, TagId), populated from the tag representation. That change is not scripted here because the bridge table and source-column parsing contract are not present in the input. It is the only durable way to make tag membership seekable and avoid the 104,581-rewind nested-loops pattern.
Evidence: Node 8 has estimated cost 193.690 and applies
LIKE '%'+TagName+'%'. Node 10 shows a Lazy Spool with 104,581 estimated rewinds.Confidence: 95%. Risk: Requires schema and data-pipeline work; delimiter and escaping rules must be defined to preserve semantics.
-
Priority 2 — Add the targeted access-path indexes in the Scripts section.
Create a filtered, narrow Posts index for PostTypeId = 1, a Posts OwnerUserId covering index, a Votes PostId covering index, and a Badges UserId covering index. These are deliberately narrower than the broad DMV suggestions because this query uses Badges.Id and does not use Class or Name.
Evidence: Posts has 740,016 rows and a 400.3 MB clustered index with 5,374 scans. Votes and Badges have 926,084 and 439,352 rows, respectively, and are scanned at Nodes 21 and 23.
Confidence: 86%. Risk: Additional write, storage, and maintenance cost; validate usage before retaining all four.
-
Priority 3 — Enable automatic statistics maintenance and refresh the heavily changed tables.
Use synchronous automatic update statistics for predictable compilation behavior. The supplied workload has no parameters, so asynchronous updates are not necessary for this particular statement and can temporarily compile against stale statistics.
Evidence: All automatic statistics settings are disabled; Votes has 1,600,424 modifications since the reported statistics updates.
Confidence: 93%. Risk: Statistics refreshes consume I/O and CPU; schedule FULLSCAN only for the targeted maintenance window.
-
Priority 4 — Resolve the apparent final join defect after establishing intended semantics.
The supplied statement joins
TopUsers.UserIdtoRankedPosts.PostId. This is not a performance-only issue: changing it to an owner-user join would change the result and therefore cannot be included as an exact-equivalence rewrite.Evidence: The final plan Hash Match probes Users.Id against Posts.Id, confirming the compiled join is UserId = PostId.
Confidence: 98% that this is the compiled predicate; intent confidence is lower because business requirements were not supplied.
-
Priority 5 — Do not add a columnstore index for this statement yet.
Batch mode on rowstore is already being used. A columnstore index would add substantial storage and write complexity while the dominant problem is the non-sargable text join and the query's multiway aggregation shape.
Evidence: The plan reports
BatchModeOnRowStoreUsed="true", and the dominant tag branch remains row-mode Nested Loops.Confidence: 82%.
Index recommendations
| Recommendation | Estimated size | Expected read benefit | Expected write overhead | Maintenance notes and evidence |
|---|---|---|---|---|
| IX_Posts_Question_TagScan Filtered on PostTypeId = 1; includes CreationDate, Title, Tags |
Approximately 90–230 MB; 740,016 table rows, with an estimated 30–55% qualifying as questions, plus 4-byte clustered key and variable-width Title/Tags payload. PAGE compression is recommended. | Approximately 15–35% for the complete statement; more for the RankedPosts branch's physical I/O, but not for substring predicate CPU. | Approximately 2–5% of Posts write cost | Reduces the 2,177-byte clustered scan payload at Node 9. The leading wildcard remains non-seekable. Risk: Tags and Title can make the index wide; monitor size and usage. |
| IX_Posts_OwnerUserId_Cover Key OwnerUserId; includes no additional columns because clustered Id is implicitly present |
Approximately 18–35 MB; 740,016 rows, 4-byte key plus implicit 4-byte clustered key and row/page overhead, PAGE compression. | Approximately 3–10% for this statement; higher for other user-to-post lookups. | Approximately 1–3% of Posts write cost | Targets Node 25's full Posts scan and DMV recommendation OwnerUserId. Risk: query-wide benefit may be modest because all users are retained by the outer join. |
| IX_Votes_PostId_VoteType Key PostId; includes VoteTypeId |
Approximately 14–28 MB; 926,084 rows, 4-byte PostId, 2-byte VoteTypeId, implicit 4-byte clustered key, row/page overhead, PAGE compression. | Approximately 5–15% for vote-to-post access; potentially much higher after safe pre-aggregation. | Approximately 1–3% of Votes write cost | Targets Node 21 and the DMV candidate with 350 seeks/scans and 61.06% impact. Risk: current plan may still prefer a scan because the query touches most votes. |
| IX_Badges_UserId_Id Key UserId; includes Id |
Approximately 8–18 MB; 439,352 rows, two 4-byte integer columns plus row/page overhead, PAGE compression. | Approximately 3–10% for badge aggregation and user-specific access. | Approximately 1–3% of Badges write cost | Targets Node 23 and the DMV UserId candidates. Risk: this query may still scan most badges because every user is preserved by LEFT JOIN. |
Size estimates are ranges because the input does not provide exact declared widths for Title, Tags, index page counts, or fill factor. Existing reported sizes were used directly where applicable: Posts clustered index 400.3 MB, Users 44.8 MB, Votes 11.8 MB, Badges 8.0 MB, Tags 0.1 MB. The new estimates assume PAGE compression and approximately 90% fill factor after normal maintenance; clustered keys are implicitly carried in nonclustered leaf rows.
Indexes not recommended: The DMV candidates involving Badges(Class/Name), Votes(UserId/BountyAmount), Users(Reputation), and unrelated Posts(CreationDate/Score/ViewCount) are not directly required by this statement. Their reported impact is workload-wide, not proof of benefit for this query.
Statistics recommendations
- Enable AUTO_CREATE_STATISTICS and AUTO_UPDATE_STATISTICS for SQLStorm.
- Refresh statistics on Posts, Votes, Badges, and Users after index creation. The supplied plan used statistics updated around 2026-09-12, while Votes had 1,600,424 modifications.
- Use FULLSCAN for the targeted statistics refresh only when the maintenance window permits it. The recommended script uses FULLSCAN for deterministic baseline quality.
- After the baseline, use a normal sampled maintenance strategy appropriate to data-change volume rather than repeated FULLSCAN operations.
- Do not add a plan guide or
OPTION (RECOMPILE). There are no parameters, and no Query Store history was supplied to show plan instability.
Evidence: The plan's OptimizerStatsUsage reports Votes statistics modification count 1,600,424. Database settings report all automatic statistics options disabled.
Storage impact
| Item | Before | After | Net change |
|---|---|---|---|
| Posts clustered index | 400.3 MB reported | 400.3 MB; unchanged | 0 MB |
| New Posts filtered index | 0 MB | 90–230 MB estimated | +90–230 MB |
| New Posts OwnerUserId index | 0 MB | 18–35 MB estimated | +18–35 MB |
| New Votes PostId index | 0 MB | 14–28 MB estimated | +14–28 MB |
| New Badges UserId index | 0 MB | 8–18 MB estimated | +8–18 MB |
| Total | Existing referenced indexes: 453.0 MB | Approximately 580–764 MB for those indexes plus recommendations | +130–311 MB estimated |
No index is recommended for removal or modification, so no storage is reclaimed and no modified-index before/after delta applies. The net estimate includes only the four new indexes and excludes unreported internal allocation, fragmentation, and statistics storage.
Before/after plan comparison
Original estimated plan
- Cost: 202.053 — critical
- Posts-to-Tags Nested Loops: estimated cost 193.690
- Posts clustered scan: 246,672 rows read; average row size 2,177 bytes
- Votes clustered scan: 926,084 rows
- Badges clustered scan: 439,352 rows
- Three Sorts, five Hash Match operators, one Lazy Spool
- Final estimated output: 71,174 rows
Expected plan after indexes and statistics
- Filtered/narrow Posts access path for PostTypeId = 1
- Potential narrow OwnerUserId, Votes.PostId, and Badges.UserId access paths
- Lower scan payload and improved join alternatives; exact operator choice must be confirmed by the optimizer
- Tag matching remains non-sargable and may still dominate CPU
- No guaranteed cost reduction is claimed until the actual plan is captured
The second panel is an expected physical-design outcome, not an observed after-plan. SQL Server may continue to choose scans when most rows are needed. The largest guaranteed architectural opportunity remains normalizing tag membership.
Scripts
Create targeted indexes implementing Priority 2
USE [SQLStorm];
GO
CREATE NONCLUSTERED INDEX [IX_Posts_Question_TagScan]
ON [dbo].[Posts] ([PostTypeId])
INCLUDE ([CreationDate], [Title], [Tags])
WHERE [PostTypeId] = 1
WITH (DATA_COMPRESSION = PAGE, ONLINE = ON, SORT_IN_TEMPDB = ON);
GO
CREATE NONCLUSTERED INDEX [IX_Posts_OwnerUserId_Cover]
ON [dbo].[Posts] ([OwnerUserId])
WITH (DATA_COMPRESSION = PAGE, ONLINE = ON, SORT_IN_TEMPDB = ON);
GO
CREATE NONCLUSTERED INDEX [IX_Votes_PostId_VoteType]
ON [dbo].[Votes] ([PostId])
INCLUDE ([VoteTypeId])
WITH (DATA_COMPRESSION = PAGE, ONLINE = ON, SORT_IN_TEMPDB = ON);
GO
CREATE NONCLUSTERED INDEX [IX_Badges_UserId_Id]
ON [dbo].[Badges] ([UserId])
INCLUDE ([Id])
WITH (DATA_COMPRESSION = PAGE, ONLINE = ON, SORT_IN_TEMPDB = ON);
GO
Enable automatic statistics maintenance implementing Priority 3
USE [SQLStorm];
GO
ALTER DATABASE [SQLStorm] SET AUTO_CREATE_STATISTICS ON;
GO
ALTER DATABASE [SQLStorm] SET AUTO_UPDATE_STATISTICS ON;
GO
ALTER DATABASE [SQLStorm] SET AUTO_UPDATE_STATISTICS_ASYNC OFF;
GO
Refresh statistics implementing Priority 3
USE [SQLStorm];
GO
UPDATE STATISTICS [dbo].[Posts] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Votes] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Badges] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Users] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Tags] WITH FULLSCAN;
GO
No rewritten query is included because the supplied query has both a potentially unintended UserId-to-PostId join and multiplicative outer joins. A rewrite that changes either join or aggregation behavior would not be guaranteed to return exactly the same rows and values as the original.
Supporting scripts (not part of the change)
Measure logical reads, CPU, elapsed time, and the actual plan
USE [SQLStorm];
GO
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SET STATISTICS XML ON;
WITH RankedPosts AS (
SELECT
p.Id AS PostId,
p.Title,
p.CreationDate,
ROW_NUMBER() OVER (PARTITION BY t.Id ORDER BY p.CreationDate DESC) AS rn,
t.TagName
FROM
Posts p
JOIN
Tags t ON p.Tags LIKE '%' + t.TagName + '%'
WHERE
p.PostTypeId = 1
),
UserStats AS (
SELECT
u.Id AS UserId,
u.DisplayName,
COUNT(DISTINCT p.Id) AS TotalPosts,
SUM(CASE WHEN v.VoteTypeId = 2 THEN 1 ELSE 0 END) AS UpVotes,
SUM(CASE WHEN v.VoteTypeId = 3 THEN 1 ELSE 0 END) AS DownVotes,
COUNT(DISTINCT b.Id) AS BadgeCount
FROM
Users u
LEFT JOIN
Posts p ON u.Id = p.OwnerUserId
LEFT JOIN
Votes v ON p.Id = v.PostId
LEFT JOIN
Badges b ON u.Id = b.UserId
GROUP BY
u.Id, u.DisplayName
),
TopUsers AS (
SELECT
us.UserId,
us.DisplayName,
us.TotalPosts,
us.UpVotes,
us.DownVotes,
us.BadgeCount,
DENSE_RANK() OVER (ORDER BY us.TotalPosts DESC, us.UpVotes - us.DownVotes DESC) AS UserRank
FROM
UserStats us
WHERE
us.TotalPosts > 10
)
SELECT
tp.UserRank,
tp.DisplayName,
tp.TotalPosts,
tp.UpVotes,
tp.DownVotes,
tp.BadgeCount,
rp.Title AS LatestPostTitle,
rp.CreationDate AS LatestPostDate,
CASE
WHEN rp.rn = 1 THEN 'Latest'
ELSE 'Older'
END AS PostStatus
FROM
TopUsers tp
LEFT JOIN
RankedPosts rp ON tp.UserId = rp.PostId
ORDER BY
tp.UserRank,
rp.CreationDate DESC;
SET STATISTICS XML OFF;
SET STATISTICS TIME OFF;
SET STATISTICS IO OFF;
GO
Inspect resulting index usage and physical sizes
USE [SQLStorm];
GO
SELECT
OBJECT_SCHEMA_NAME(i.object_id) AS SchemaName,
OBJECT_NAME(i.object_id) AS TableName,
i.name AS IndexName,
i.type_desc,
ips.page_count,
CONVERT(decimal(18,2), ips.page_count * 8.0 / 1024.0) AS SizeMB,
i.fill_factor,
ips.avg_fragmentation_in_percent
FROM sys.indexes AS i
JOIN sys.dm_db_index_physical_stats
(
DB_ID(),
NULL,
NULL,
NULL,
'SAMPLED'
) AS ips
ON ips.object_id = i.object_id
AND ips.index_id = i.index_id
WHERE OBJECT_SCHEMA_NAME(i.object_id) = 'dbo'
AND OBJECT_NAME(i.object_id) IN ('Posts', 'Votes', 'Badges', 'Users', 'Tags')
ORDER BY TableName, IndexName;
GO
SELECT
OBJECT_SCHEMA_NAME(i.object_id) AS SchemaName,
OBJECT_NAME(i.object_id) AS TableName,
i.name AS IndexName,
us.user_seeks,
us.user_scans,
us.user_lookups,
us.user_updates
FROM sys.indexes AS i
LEFT JOIN sys.dm_db_index_usage_stats AS us
ON us.database_id = DB_ID()
AND us.object_id = i.object_id
AND us.index_id = i.index_id
WHERE OBJECT_SCHEMA_NAME(i.object_id) = 'dbo'
AND OBJECT_NAME(i.object_id) IN ('Posts', 'Votes', 'Badges', 'Users', 'Tags')
ORDER BY TableName, IndexName;
GO
Confidence and validation
| Area | Confidence | Reason |
|---|---|---|
| Tag join is the dominant bottleneck | 95% | Node 8 cost 193.690 versus total cost 202.053; leading-wildcard LIKE is visible in the plan. |
| Foreign-key indexes can reduce access cost | 86% | Votes and Badges are full clustered scans; DMV reports relevant PostId/UserId candidates. |
| Statistics maintenance is required | 93% | All automatic statistics settings are disabled and Votes has 1,600,424 modifications. |
| Actual tempdb spill exists | 20% | Sorts and a large desired grant are present, but no spill warning or runtime spill data is supplied. |
| Final UserId-to-PostId join is unintended | 75% | The compiled predicate is confirmed, but business intent is unavailable. |
Validate changes by running the supporting measurement script before and after applying the recommendations. Compare logical reads, CPU time, elapsed time, actual row counts, memory grants, spills, and the actual operator choices. Retain an index only if its read benefit justifies its measured write and maintenance overhead.