Query Tuner Report – RockyPC / SQLStorm – QueryHash 0x872C7773968C6AAF

Tuning goal: Query Tuner

Context: Server RockyPC · Database SQLStorm · SQL Server 2022 RTM-GDR 16.0.1200.5 · Developer Edition with Enterprise engine capabilities · Query hash 0x872C7773968C6AAF · Plan hash 0x06B74AE8F0C628E7.

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.

  1. 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.
  2. 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.
  3. 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.
  4. Review the query's join correctness before production use. The final join is tp.UserId = rp.PostId, although RankedPosts exposes a post identifier. This is preserved in the supplied statement, but it likely produces unintended matches.
Important semantic limitation: A conventional pre-aggregation rewrite of 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

ItemObserved value
Server / databaseRockyPC / SQLStorm
EngineSQL Server 2022, version 16.0.1200.5, RTM-GDR
Edition capabilitiesDeveloper Edition; Enterprise engine features reported as available, including online operations, compression, columnstore, and IQP features
Estimated statement cost202.053 — critical
Estimated output71,174.1 rows
Plan characteristicsBatch mode on rowstore; one parallel branch; three Sort operators; five Hash Match operators
Query StoreNo matching Query Store history was supplied
ParametersNo 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

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

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

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

  4. Priority 4 — Resolve the apparent final join defect after establishing intended semantics.

    The supplied statement joins TopUsers.UserId to RankedPosts.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.

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

RecommendationEstimated sizeExpected read benefitExpected write overheadMaintenance 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

ItemBeforeAfterNet change
Posts clustered index400.3 MB reported400.3 MB; unchanged0 MB
New Posts filtered index0 MB90–230 MB estimated+90–230 MB
New Posts OwnerUserId index0 MB18–35 MB estimated+18–35 MB
New Votes PostId index0 MB14–28 MB estimated+14–28 MB
New Badges UserId index0 MB8–18 MB estimated+8–18 MB
TotalExisting referenced indexes: 453.0 MBApproximately 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

AreaConfidenceReason
Tag join is the dominant bottleneck95%Node 8 cost 193.690 versus total cost 202.053; leading-wildcard LIKE is visible in the plan.
Foreign-key indexes can reduce access cost86%Votes and Badges are full clustered scans; DMV reports relevant PostId/UserId candidates.
Statistics maintenance is required93%All automatic statistics settings are disabled and Votes has 1,600,424 modifications.
Actual tempdb spill exists20%Sorts and a large desired grant are present, but no spill warning or runtime spill data is supplied.
Final UserId-to-PostId join is unintended75%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.