Query Tuner – RockyPC/SQLStorm – QueryHash 0x872C7773968C6AAF

Tuning goal: Query Tuner — reduce execution cost and eliminate avoidable work while preserving the query's current result set.

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

Executive summary

196.93Estimated statement cost — critical
157.29Nested Loops own cost — about 80% of statement cost
104,436Rows entering tag matching and windowing
5.5 GBEstimated desired memory, not measured granted memory
  1. Critical: fix the tag-matching design. The join p.Tags LIKE '%' + t.TagName + '%' is evaluated by a nested-loops plan over approximately 104,436 question rows and 1,232 tags. The nested loops operator has own cost 157.2936, and the spool subtree is 33.6225.
  2. Critical correctness review: validate the final join. The query joins tp.UserId = rp.PostId, not a user/post-owner relationship. This may be intentional, but it is a strong semantic anomaly. Changing it would not preserve the current result set, so no corrective rewrite is supplied as an equivalent rewrite.
  3. High: remove aggregate row multiplication for the intended business result. Posts, Votes, and Badges are joined before aggregation, producing an estimated 1,677,770 intermediate rows and requiring two distinct aggregates. A corrected business rewrite could be materially faster, but it would change results where a user has both multiple badges and multiple vote rows.
  4. High: refresh statistics. Automatic statistics creation and update are disabled. Votes statistics show 1,600,424 modifications since their last update on 2026-09-12, while the plan relies on those statistics.
  5. Do not blindly add DMV missing indexes. The supplied plan's dominant cost is not an uncovered equality seek. Existing indexes already support the Votes/PostId and Badges/UserId scans, and the DMV candidates are workload-wide recommendations without size or plan-specific benefit evidence.
Bottom line: statistics maintenance is the safe immediate change. The largest query-specific gain requires changing the denormalized tag relationship or introducing a maintained tag bridge/full-text-compatible design. Adding another conventional index on Posts.Tags will not make a leading-wildcard predicate seekable.

Environment and scope

ItemReported value
Server/databaseRockyPC / SQLStorm
EngineSQL Server 2022, build 16.0.1200.5, RTM-GDR
Edition/capabilitiesDeveloper Edition; Enterprise engine capabilities reported; online operations, compression, columnstore, partitioning, IQP and PSP available
Statistics scopeUsage, missing-index, operational and plan-cache data cover only the 37.6-hour period since the 2026-09-25 23:38:04 restart
Query StoreNo matching Query Store history; parameter-sensitive behavior cannot be assessed from Query Store

Evidence: The input reports 37.6 hours of uptime and explicitly states that DMVs and plan cache were cleared at restart. The plan uses CE model 160 and batch mode on rowstore.

Plan diagnosis

PriorityFindingEvidence and impact
Critical Leading-wildcard tag join Nested Loops Node 8 has own cost 157.2936 and 104,436 estimated rows. Its residual predicate is Posts.Tags LIKE Expr1020, where the expression is '%' + Tags.TagName + '%'. This prevents a normal B-tree seek on Posts.Tags.
High Lazy spool repeated for tag matching Table Spool Node 10 has estimated 1,232 rows, 104,435 rewinds and subtree cost 33.6225. The spool avoids rescanning Tags physically, but does not remove the repeated wildcard comparisons.
High Large aggregate input and distinct aggregates Hash Aggregate Node 18 receives an estimated 1,677,770 rows and computes both COUNT(DISTINCT p.Id) and COUNT(DISTINCT b.Id). The plan's intermediate row volume is substantially greater than the 267,193 Users rows, 246,672 Posts rows used in the join, and 439,352 Badges rows scanned.
Medium Three sorts and wide rows The plan contains Sort operators for tag row numbering, user ranking and final ordering. The final Sort handles 63,469 estimated rows with average row size 721 bytes. The tag sort handles 104,436 rows with average row size 177 bytes.
Medium Potentially excessive memory grant Estimated serial desired memory is 5,783,616 KB, while the estimated plan reports granted and used memory as unknown. No spill warning is present in the supplied XML, so an actual spill cannot be concluded.
Low Implicit conversions in aggregate outputs Compute Scalar Nodes 17/15 contain CONVERT_IMPLICIT(int,...) for aggregate expressions. This is not a join or filter conversion and is unlikely to be the main bottleneck, but explicit output typing can make intent clearer.
Low No parameter sniffing evidence The supplied query has no parameters, and the plan contains no parameter references. Parameter Sensitive Plan optimization and plan guides are therefore not applicable to this execution.

Detailed prioritized recommendations

1. Redesign the denormalized tag relationship before adding another Posts index

Recommendation: Replace substring matching against Posts.Tags with a relational post/tag bridge or another semantically equivalent tag-search implementation. The current predicate searches for a tag name anywhere in a delimited string, so an ordinary index on Tags cannot seek to the substring.

Expected benefit: This targets the plan's dominant operator: Nested Loops Node 8, own cost 157.2936, approximately 80% of the 196.93 statement cost. The current tag branch processes 104,436 question rows and performs wildcard matching against 1,232 tag rows.

Important semantic note: A bridge table must preserve the current substring semantics if exact row equivalence is required. A tokenized bridge may differ for substrings, delimiters, escaping and case/collation behavior. Full-text search also has different tokenization semantics and should not be substituted without an approved result-set change.

Evidence: Node 8 shows Posts.Tags LIKE '%' + Tags.TagName + '%'; Node 9 reads 104,436 question rows; Node 10 has 104,435 rewinds and subtree cost 33.6225.

Risk: High implementation and data-maintenance risk because the schema/data model changes. No executable object-creation script is supplied because the input does not define a bridge table, change-capture process, or exact tag-token semantics.

2. Validate the apparent UserId/PostId join defect before any result-changing rewrite

Recommendation: Review LEFT JOIN RankedPosts rp ON tp.UserId = rp.PostId. The CTE exposes p.Id AS PostId, while the outer row is keyed by tp.UserId. This associates a user with a post whose identifier equals the user identifier, rather than associating the user's own posts.

Why no rewrite is provided: Replacing this condition with rp.OwnerUserId = tp.UserId would likely represent the intended business rule, but it would not return exactly the same rows as the supplied query. The requested tuning constraint requires result preservation.

Evidence: The plan's final Hash Match Node 4 builds on Posts.Id and probes with Users.Id. The query text explicitly specifies tp.UserId = rp.PostId.

Risk: Very high if changed without business approval; result cardinality and displayed latest-post data can change. Confidence that this is a correctness anomaly: 95%; confidence that it is unintended: 75%, because intent was not separately documented.

3. Separate the Posts/Votes/Badges aggregates in a future approved business rewrite

Recommendation: For the intended user-statistics result, aggregate Posts, Votes and Badges independently by user, then join the three compact aggregate sets to Users. This prevents the multiplication of vote rows by badge rows and avoids the need for two distinct aggregates over the multiplied stream.

Expected benefit: The current aggregate branch receives 1,677,770 estimated rows at Node 19 and uses Hash Aggregate Node 18 with two distinct aggregates. The source tables contain 1,852,168 Votes, 878,704 Badges, 1,091,124 Posts and 534,386 Users rows.

Result-preservation limitation: This is not included as a runnable “rewritten query” because it changes the current arithmetic whenever a user has multiple badges and multiple vote rows. The supplied query's SUM(CASE...) values are multiplied by badge cardinality for users with posts and badges.

Evidence: Node 18 computes COUNT(DISTINCT Posts.Id), vote sums and COUNT(DISTINCT Badges.Id) after joins; Node 19 estimates 1,677,770 rows. No plan warning indicates a missing join predicate; the multiplication follows directly from the query's join shape.

Risk: High semantic risk unless the intended counts are confirmed. Performance confidence is high for workloads with many users having both votes and badges, but the exact runtime reduction is unavailable because no actual execution metrics were supplied.

4. Refresh statistics immediately, then enable automatic statistics management

Recommendation: Run the supplied statistics-maintenance script in a controlled maintenance window. Then enable AUTO_CREATE_STATISTICS, AUTO_UPDATE_STATISTICS and, where acceptable, asynchronous updates for SQLStorm.

Expected benefit: Better cardinality estimates can reduce memory-grant errors and improve join/aggregate choices. This will not eliminate the leading-wildcard cost, but it addresses stale input information used by the compiled plan.

Evidence: Database settings report all three automatic statistics options disabled. Votes statistics were last updated on 2026-09-12 with modification count 1,600,424 and sampling 71.5712%; the plan uses those statistics.

Risk: Statistics updates consume CPU and I/O and can compile plans. FULLSCAN on the largest referenced objects may be expensive; the script uses targeted updates rather than a database-wide operation.

5. Do not add the DMV missing-index candidates for this query without workload proof

Recommendation: Do not implement the reported DMV candidates solely from their percentages. The most relevant missing-index-looking item for the current join would be Votes(UserId) INCLUDE (VoteTypeId), but this query joins Votes by PostId, and the existing IX_Votes_PostId_VoteType already supports that access.

Evidence: The plan scans IX_Votes_PostId_VoteType, reading 926,084 rows at cost 1.05937, and scans IX_Badges_UserId_Id, reading 439,352 rows at cost 0.502074. The DMV recommendation for Votes.UserId has only 10 total seeks/scans in the post-restart window.

Risk: Adding workload-specific indexes can increase DML, storage and maintenance cost without improving this statement. The supplied index usage statistics show zero reported updates during this short observation window, so write overhead cannot be quantified from the input.

6. Treat sort and memory-grant tuning as secondary

Recommendation: Do not force join algorithms, MAXDOP or memory-grant hints from this estimated plan. First address tag matching, aggregate shape and statistics. Capture an actual plan to determine whether any sort spills.

Evidence: Three Sort operators are present, but their individual own costs are only 0.0580, 0.1100 and 0.0586. Estimated desired memory is 5,783,616 KB, while granted/used memory is explicitly unknown and no spill warning is supplied.

Risk: Hints can stabilize a single estimate while harming other executions. No parameter exists in this query, so parameter sniffing remediation is not applicable.

Index assessment

Index/tableReported usage and sizeAssessment
Posts.IX_Posts_Question_TagScan Seeks 18, scans 0, lookups 0, updates 0; 10.5 MB, PAGE compression Useful for PostTypeId = 1 and covers CreationDate, Title and Tags. It already reduces the question branch to an index seek, but cannot make the leading wildcard searchable. Retain.
Posts.IX_Posts_OwnerUserId_Cover Seeks 46, scans 82, lookups 0; 2.4 MB, PAGE compression Used by the user-statistics branch and reads 246,672 estimated rows. Retain.
Votes.IX_Votes_PostId_VoteType Seeks 90, scans 76, lookups 0; 9.8 MB, PAGE compression Used by the query and covers PostId/VoteTypeId. The 926,084-row scan is cheaper than adding a redundant PostId index. Retain.
Badges.IX_Badges_UserId_Id Seeks 34, scans 40, lookups 0; 4.6 MB, PAGE compression Used by the query and covers UserId/Id. Retain.
Clustered indexes on Users, Posts, Votes, Badges and Tags Reported sizes: Users 44.8 MB; Posts 400.3 MB; Votes 11.8 MB; Badges 8.0 MB; Tags 0.1 MB No drop or modification is justified. All have scans or seeks in the observed period, and clustered-key changes would have broad impact.

Evidence: The plan uses all listed nonclustered indexes in the relevant branches. The observation period is only 37.6 hours, so low update counts are not evidence that an index has no write cost.

Storage impact

RecommendationEstimated size and assumptionsNet storage impact
Statistics refresh/automatic statistics settings Statistics storage is not reported separately in the input. Not quantified; no index storage change.
No new conventional index No missing-index candidate in the supplied data has a reported physical size. Column widths and row counts are available for some candidates, but the candidates are not justified for this query. 0 MB index change.
No index drop or modification Existing index sizes are reported but no index is recommended for removal. 0 MB reclaimed; 0 MB modified-index net change.
Future normalized tag bridge Cannot estimate responsibly: bridge row count, key widths, tag-token rules and maintenance design are not provided. Range unavailable from supplied metadata; a firm MB/GB estimate requires those missing inputs.

Current reported referenced-index footprint: approximately 482.3 MB, calculated by summing the nine reported index sizes: 44.8 + 400.3 + 9.8 + 2.4 + 11.8 + 4.6 + 8.0 + 10.5 + 0.1 MB. This is an estimate based on the supplied rounded sizes, not a new storage measurement.

Evidence: The input reports sizes for every referenced index, but no size for a new tag bridge or for the DMV missing-index candidates. No recommendation can legitimately claim a new-index MB value without those dimensions.

Before/after plan comparison

AreaCurrent planExpected optimized directionConfidence
Tag lookup Nested Loops, 104,436 question rows, wildcard residual predicate, own cost 157.2936; Tags spool rewinds 104,435 times. Use a maintained tag relationship that can seek or directly join by post/tag key. The exact optimized operator and row count require schema/data changes and an actual recompile. High for bottleneck identification; medium for benefit magnitude.
User statistics Hash joins followed by a 1,677,770-row aggregate with two distinct counts. For the intended business logic, independently aggregate Posts, Votes and Badges before joining to Users. This is a result-changing rewrite relative to the current text and is not supplied as an equivalent query. High for row-multiplication diagnosis.
Statistics Automatic create/update disabled; Votes modification count 1,600,424 since last update. Updated histograms and enabled automatic maintenance should improve estimates; operator changes cannot be guaranteed from estimated XML alone. High for maintenance recommendation.
Memory/sorts Three sorts; desired memory 5,783,616 KB; actual grant/use and spills unknown. Measure an actual execution after upstream changes. Avoid hints until spill and runtime data exist. High for caution, low for predicted runtime change.

Scripts

Refresh statistics used by the query and enable automatic statistics management — Recommendation 4

USE [SQLStorm];
GO

UPDATE STATISTICS [dbo].[Votes] [_WA_Sys_00000003_5BE2A6F2] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Votes] [ST_Votes_VoteTypeId_CreationDate_PostId] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Votes] [IX_Votes_PostId_VoteType] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Votes] [ST_Votes_Post_VoteType_User] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Posts] [PK__Posts__3214EC07AF21AC3E] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Posts] [IX_Posts_Question_TagScan] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Posts] [ST_Posts_PostType_Creation_Owner] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Posts] [IX_Posts_OwnerUserId_Cover] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Posts] [_WA_Sys_00000002_440B1D61] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Posts] [_WA_Sys_00000005_440B1D61] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Badges] [ST_Badges_User_Class_Date] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Badges] [_WA_Sys_00000002_628FA481] WITH FULLSCAN;
GO
UPDATE STATISTICS [dbo].[Users] [PK__Users__3214EC079D12375D] WITH FULLSCAN;
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 ON;
GO

Implementation note: The script changes only statistics and database statistics settings. It does not add, drop or modify indexes, so its index storage impact is 0 MB.

Supporting scripts (not part of the change)

Capture actual I/O, CPU time and execution plan for before/after validation

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 AS p
    JOIN Tags AS 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 AS u
    LEFT JOIN Posts AS p
        ON u.Id = p.OwnerUserId
    LEFT JOIN Votes AS v
        ON p.Id = v.PostId
    LEFT JOIN Badges AS 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 AS 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 AS tp
LEFT JOIN RankedPosts AS 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

Validation requirement: Compare logical reads, CPU time, elapsed time, actual rows, memory grant, spills and returned-row checksum before and after the statistics change. The supplied plan is estimated only, so actual performance cannot be claimed yet.

Confidence and validation

ConclusionConfidenceBasis
Leading-wildcard tag join is the primary bottleneck98%Nested Loops own cost 157.2936, approximately 80% of statement cost, with an explicit wildcard residual predicate and 104,436 estimated rows.
Statistics are stale or insufficiently maintained98%Automatic statistics options are disabled; Votes has 1,600,424 modifications since its reported update.
Current aggregate shape multiplies rows99%Independent one-to-many joins are followed by aggregate expressions over the combined stream; Node 19 estimates 1,677,770 rows.
UserId/PostId join is a likely correctness defect95% anomaly / 75% unintendedThe query explicitly joins those unrelated logical roles, but business intent was not supplied.
A new conventional index will solve tag matching1% confidenceThe predicate begins with a wildcard and the existing filtered covering index already supports PostTypeId filtering.

Validate all changes with SET STATISTICS IO ON and SET STATISTICS TIME ON, plus an actual execution plan. Confirm that the output rows and values remain unchanged for any change claimed to be performance-only. Because Query Store has no matching history and the server restarted 37.6 hours ago, reported usage and missing-index figures should be treated as provisional.