Quick summary:
Slow dashboards on a large fact table are usually an indexing problem, and adding compute only delays the fix. Our team at ScriptsHub Technologies used Query Store, the missing-index and index-usage DMVs, and actual execution plans to find three bottlenecks on a financial services client’s 180-million-row settlement table: a full clustered index scan, repeated key lookups, and a status filter that scanned every row. One covering index, one filtered index, a minor index realignment, and the removal of two unused indexes cut median dashboard load time from 42 to 15 seconds (about 64%) with no table changes and no downtime. This guide covers the diagnosis, the T-SQL, validation, index maintenance, and the eight-step workflow we now reuse.
Why are SQL Server queries slow on large fact tables?
Large fact tables slow down when the optimizer has no narrow path to the needed rows and must scan millions of them; SQL Server index tuning builds that path.
If your dashboards time out every morning, reports that once took 5 seconds now take close to a minute, and each compute upgrade buys only weeks of relief, the cause is usually index design rather than server size. We saw exactly this on a financial services operations dashboard.
Analysts waited 40 to 60 seconds for reports. The multi-terabyte database ran on Azure SQL Managed Instance, transaction volume had grown sharply, and the dashboard queried a 180-million-row settlement fact table joined to six dimensions. Scaling up only raised costs, so index tuning was where we started.
How do you diagnose slow queries with SQL Server index tuning?
Measure before you change anything: rank the heaviest queries in Query Store, read their actual execution plans, and check the missing-index and index-usage DMVs.
SQL Server index tuning is the practice of auditing index usage, query plans, and workload patterns to find the smallest set of index changes that turns expensive scans into targeted index seeks. We ranked queries in the Top Resource Consuming Queries report of Query Store, captured actual execution plans in SSMS (Ctrl+M), and cross-checked the DMVs.
Three symptoms stood out. The top offender, a date-range query on the settlement table, scanned all 180 million rows through the clustered index on every run. Two smaller queries triggered key lookups because the existing nonclustered indexes lacked the columns in their SELECT lists. A third query filtered on an open status matching only 2% of rows, yet still scanned the full table.
Covering, filtered, or plain nonclustered index: which one fits?
Use a covering index to remove key lookups on hot read queries, a filtered index when a query always targets a small subset, and a plain nonclustered index only for simple predicate lookups.

We chose a blended plan: one covering index, one filtered index, a minor index realignment, and the removal of indexes nobody was reading.
How we fixed the slow queries with covering and filtered indexes
We added one covering index and one filtered index, both built online with page compression, and dropped two indexes that had served no reads in 90 days.
The reporting query filtered on SettlementDate, joined on MerchantId, and returned seven more columns. We made the two predicate columns the index key and placed the seven output columns in INCLUDE.

Why this works: Included columns live only at the index leaf level, so the query gets every column without returning to the clustered index, and the key stays narrow.
SettlementDateleads because it is the range predicate every report filters on. Page compression shrank this index by about 55%.
Next came the filtered index for the open-status query.

Why this works: A filtered index stores only the rows that match its
WHEREclause. Here it occupied 60 MB instead of the roughly 4 GB a full-table equivalent would have needed, and the optimizer chose it automatically because the ORM sentStatusCode = 'OPEN'as a literal.When to use which: Choose a filtered index only for stable predicates sent as literals. Parameterized values or forced parameterization stop the optimizer from matching the filter; the plan then shows an
UnmatchedIndexeswarning, whichOPTION (RECOMPILE)or a literal predicate clears. Sessions that modify the table need the required SET options, such asANSI_NULLS, listed in the CREATE INDEX reference.
Finally, two indexes showed no seeks, scans, or lookups in 90 days. Dropping them freed about 9 GB and cut write overhead, though uptime matters, because index usage statistics reset when the engine restarts.
If your reporting tier shows the same timeouts and rising compute bills, our data engineering consulting team can run this diagnosis before you commit to a bigger compute size.
How to validate index changes before production
Test on a non-production copy at the same service tier, compare logical reads, CPU time, and elapsed time per query, and measure write throughput before you ship.
We restored a backup to a non-production instance on the same tier and replayed a 48-hour workload window. We compared the top 50 queries against the Query Store baseline. The covering index cut logical reads on the reporting query by 98%. The status query fell from a 4-second full scan to 30 milliseconds on the filtered index
Every index adds work to inserts, deletes, and updates that touch its columns, so we measured the settlement ETL too. Insert duration rose about 6%, an acceptable trade.
How to verify it worked: Compare plan hashes before and after. The reporting query should now seek on
IX_Settlement_ReportCoverwith no Key Lookup operator in the plan, and the status query should readIX_Settlement_OpenStatusinstead of the clustered index.
What results did the index changes deliver?
After two weeks in production, median dashboard load time fell from 42 to 15 seconds and the client scaled down one compute size.
SQL Server index tuning cut median load time by about 64%, and P95 latency fell from 58 to 21 seconds. Results vary by workload and data shape. Scaling down one compute size cut monthly cost by about 20%, with no table changes, query rewrites, or downtime in an engagement of under a week. Our SQL Server linked server performance fix on an AS/400 integration followed the same pattern.
How do you maintain SQL Server indexes after tuning?
Base index maintenance on measured page density and query impact, and keep statistics current so the optimizer keeps choosing the indexes you built.
The familiar rule of reorganizing at 5 to 30% fragmentation and rebuilding above 30% is only a starting point; Microsoft’s index maintenance guidance warns against fixed thresholds alone because maintenance costs CPU, log, and I/O. Check fragmentation and page density in sys.dm_db_index_physical_stats using SAMPLED or DETAILED mode, use ALTER INDEX ... REORGANIZE for light cleanup, and save REBUILD WITH (ONLINE = ON) for indexes where low page density inflates reads. We run it monthly in the client’s maintenance window.
Also keep AUTO_UPDATE_STATISTICS on and run UPDATE STATISTICS on large fact tables after heavy loads or late-arriving data backfills, because stale row estimates can push the optimizer back to a scan.
SQL Server index tuning checklist: our 8-step workflow
We run this workflow whenever slow queries are the symptom.
Step 1: Capture the baseline. Pull the top 20 queries by duration, CPU, and logical reads from Query Store and record their plan hashes.
Step 2: Find missing indexes. Join sys.dm_db_missing_index_details to sys.dm_db_missing_index_group_stats through sys.dm_db_missing_index_groups and rank suggestions by avg_total_user_cost * avg_user_impact * (user_seeks + user_scans) per Microsoft’s missing index guidance, treating each as one input among several.
Step 3: Remove unused and redundant indexes. Left-join sys.indexes to sys.dm_db_index_usage_stats, since indexes unused since restart have no row, and flag those with no reads in 90 days but steady user_updates. Drop left-prefix duplicates unless they enforce a constraint: an index on (LastName, FirstName) already serves LastName seeks. Script each definition, or disable the index first with ALTER INDEX ... DISABLE, and test drops in non-production.
Step 4: Run execution plan analysis. Run the worst queries in SSMS with Include Actual Execution Plan (Ctrl+M) and SET STATISTICS IO, TIME ON. Clustered index scans, key lookups, and hash matches on small results each signal an indexing opportunity.
Step 5: Design covering or filtered indexes. Lead the key with the filter or join column, then order the rest from most to least distinct, per Microsoft’s SQL Server index design guide. Put SELECT columns in INCLUDE and add a WHERE clause for narrow literal subsets. Keep the clustered key narrow, unique, static, and ever-increasing, since every nonclustered index carries it.
Step 6: Build online and compressed. Use ONLINE = ON on Enterprise edition, Azure SQL Database, or Azure SQL Managed Instance. Online builds are slower, need extra disk space, and are fully logged. Run sp_estimate_data_compression_savings before choosing page compression.
Step 7: Validate on a non-production replica. Replay a representative workload, compare logical reads and elapsed time, and watch insert throughput. This is especially important on tables fed by incremental data loading in Azure Synapse.
Step 8: Maintain and monitor. Review Query Store, index usage, page density, and statistics freshness monthly. Index needs drift as workloads change, so SQL Server index tuning works best as an ongoing practice.
Key takeaways for tuning SQL Server indexes
Index tuning is often the highest-return step in SQL query optimization, and it usually beats a hardware upgrade on cost and durability.
The fastest way to speed up a slow query is a better path to the data. Covering indexes remove key lookups, filtered indexes serve narrow subsets cheaply, and dropping dead indexes pays back on every write. As our Microsoft Fabric capacity throttling case showed, more capacity rarely fixes a design problem. If reports still lag on a healthy database, look next at Power BI DAX optimization. Also consider the DirectQuery vs Import storage mode choice.
If your SQL Server workload is slowing down, contact ScriptsHub Technologies to schedule a database performance tuning review before paying for bigger hardware, or connect with us on LinkedIn.
Frequently asked questions
Q. What is the difference between a clustered and nonclustered index?
A clustered index stores the table’s rows in key order, so each table has only one. A nonclustered index is a separate structure with pointers back to those rows, and a table can have several.
Q. How do I find missing indexes in SQL Server?
Query sys.dm_db_missing_index_details with the related missing-index DMVs, or run the slow query in SSMS with the actual execution plan on and check the green missing-index hint above the plan.
Q. What is an index seek vs. an index scan, and which is better?
An index seek is usually faster for selective queries because SQL Server jumps straight to the matching rows. An index scan reads the whole index, which wins when a query needs most rows or the table is small.
Q. How do INCLUDE columns fix key lookups?
They turn the index into a covering index. Included columns are stored at the leaf level. So SQL Server returns every column the query needs without an extra trip to the base table.
Q. When should I rebuild vs. reorganize an index?
Reorganize when fragmentation is moderate, commonly 5% to 30%; it is lightweight and always online. Rebuild above roughly 30% to recreate the index. Treat both as starting points and check page density too.




