A SQL Server database’s indexes can have a tremendous impact on performance for queries as well as updates and inserts. But determining the “best” indexes is not easy. Even if they were perfect when the database was first developed, as data volume increases, queries change, or you add new tables or columns, they will need to be adjusted to maintain high performance.
You can spend hours doing it the “old-fashioned” way, or you can optimize your indexes with AI SQL Tuner to accelerate the process.
Table of Contents
Index Tuning Recommendations Example #1 – TPC-H benchmark
This is an example of index recommendations using TPC-H database provided by HammerDB.
The results were generated in just seconds using GPT-5, saving hours of effort for an experienced DBA or SQL Server database developer.
AI SQL Tuner Recommendations
Tuning goal: Index Tuning
Server: RockyPC
SQL Server Version: Microsoft SQL Server 2022 (RTM-GDR) – 16.0.1160.1 (X64)
Database: tpch
Executive Summary
These recommendations prioritize high-impact, low-overhead index additions to accelerate common filters and ranges on read-mostly tables, consolidate overlapping missing index requests, and remove clearly underutilized indexes. All proposed nonclustered indexes use PAGE compression to minimize I/O and storage, and conservative fill factors suitable for the workload characteristics observed.
- Create consolidated covering index on dbo.part: Nonclustered index on (p_size, p_brand) INCLUDE (p_type, p_mfgr), PAGE compressed. This satisfies two separate missing-index patterns in one structure with minimal write overhead.
- Create targeted lookup index on dbo.partsupp: Nonclustered index on (ps_suppkey) INCLUDE (ps_supplycost, ps_availqty), PAGE compressed. Supports seeks by supplier over partsupp where the clustered PK begins with ps_partkey.
- Create range-supporting index on dbo.customer: Nonclustered index on (c_acctbal) INCLUDE (c_phone), PAGE compressed, to accelerate inequality/range queries on account balance while covering phone lookups.
- Drop unused index on dbo.lineitem: Drop IX_lineitem_l_suppkey_l_partkey_cov (0 seeks, 1 scan, 0 updates), reducing storage and maintenance exposure without observed benefit.
- Statistics maintenance: Fullscan updates on affected tables post-deployment; ensure auto stats settings are enabled; add explicit column statistics only if auto-create is disabled.
- Optional compression/fill factor adjustment for write-heavy clustered index on dbo.lineitem: Consider PAGE compression if CPU is available and set moderate fill factor to mitigate page splits. This is optional and should follow index additions above.
Detailed Prioritized Recommendations
1) Create consolidated covering index on dbo.part
- Rationale:
- Two missing index suggestions target dbo.part:
- Equality on p_size INCLUDE (p_type, p_mfgr)
- Inequality on p_size, p_brand INCLUDE (p_type)
- A single index on (p_size, p_brand) INCLUDE (p_type, p_mfgr) satisfies both patterns, reducing index count and write overhead.
- Index usage shows dbo.part is read-heavy (3,190 seeks, 510 scans, negligible updates), making PAGE compression advantageous.
- Two missing index suggestions target dbo.part:
- Script:
USE tpch;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.part')
AND name = N'IX_part_p_size_brand_inc_type_mfgr'
)
BEGIN
CREATE NONCLUSTERED INDEX IX_part_p_size_brand_inc_type_mfgr
ON dbo.part (p_size ASC, p_brand ASC)
INCLUDE (p_type, p_mfgr)
WITH (
DATA_COMPRESSION = PAGE,
FILLFACTOR = 100,
SORT_IN_TEMPDB = ON,
ONLINE = ON
);
END
GO
Consider verifying column nullability and selectivity; if p_brand is frequently filtered, its position as second key helps inequality and composite predicates.
2) Create targeted lookup index on dbo.partsupp
- Rationale:
- Missing index suggests equality on ps_suppkey INCLUDE (ps_supplycost, ps_availqty).
- Existing clustered key is partsupp_pk (likely (ps_partkey, ps_suppkey)); filtering by suppkey alone benefits from an access path beginning with ps_suppkey.
- Table is read-heavy with no observed updates; PAGE compression yields significant I/O savings with minimal CPU overhead on this workload.
- Script:
USE tpch;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.partsupp')
AND name = N'IX_partsupp_ps_suppkey_inc_cost_qty'
)
BEGIN
CREATE NONCLUSTERED INDEX IX_partsupp_ps_suppkey_inc_cost_qty
ON dbo.partsupp (ps_suppkey ASC)
INCLUDE (ps_supplycost, ps_availqty)
WITH (
DATA_COMPRESSION = PAGE,
FILLFACTOR = 100,
SORT_IN_TEMPDB = ON,
ONLINE = ON
);
END
GO
3) Create range-supporting index on dbo.customer
- Rationale:
- Missing index recommends inequality on c_acctbal INCLUDE (c_phone).
- Current access primarily via clustered PK; no nonclustered exists on c_acctbal. A focused index greatly improves range queries and sorts on c_acctbal.
- Customer table shows read-heavy access; added maintenance overhead is low, and compression reduces I/O.
- Script:
USE tpch;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.customer')
AND name = N'IX_customer_c_acctbal_inc_phone'
)
BEGIN
CREATE NONCLUSTERED INDEX IX_customer_c_acctbal_inc_phone
ON dbo.customer (c_acctbal ASC)
INCLUDE (c_phone)
WITH (
DATA_COMPRESSION = PAGE,
FILLFACTOR = 100,
SORT_IN_TEMPDB = ON,
ONLINE = ON
);
END
GO
4) Drop unused index on dbo.lineitem
- Rationale:
- IX_lineitem_l_suppkey_l_partkey_cov shows 0 seeks, 1 scan, 0 updates — effectively unused in observed workload.
- While current updates are 0, keeping unused indexes increases storage, rebuild/reorganize time, and potential future write overhead.
- Script (safe drop with existence check):
USE tpch;
GO
IF EXISTS (
SELECT 1
FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.lineitem')
AND name = N'IX_lineitem_l_suppkey_l_partkey_cov'
)
BEGIN
DROP INDEX IX_lineitem_l_suppkey_l_partkey_cov ON dbo.lineitem WITH (ONLINE = ON);
END
GO
Reassess after a representative workload period. If plans begin using a similar access path, consider recreating a better-targeted covering index based on observed predicates.
5) Statistics recommendations
- Rationale:
- New indexes will auto-create index stats; update them after creation to optimize cardinality estimates.
- Ensure AUTO_CREATE_STATISTICS and AUTO_UPDATE_STATISTICS are enabled at the database level.
- Given read-heavy nature, a fullscan update on key tables is appropriate post-deployment.
- Script:
USE tpch;
GO
-- Ensure auto stats settings are enabled (adjust only if currently disabled)
ALTER DATABASE tpch SET AUTO_CREATE_STATISTICS ON;
ALTER DATABASE tpch SET AUTO_UPDATE_STATISTICS ON;
ALTER DATABASE tpch SET AUTO_UPDATE_STATISTICS_ASYNC ON;
-- Update statistics on impacted tables and large fact tables
UPDATE STATISTICS dbo.part WITH FULLSCAN;
UPDATE STATISTICS dbo.partsupp WITH FULLSCAN;
UPDATE STATISTICS dbo.customer WITH FULLSCAN;
-- Optionally refresh lineitem and orders after index changes
UPDATE STATISTICS dbo.lineitem WITH FULLSCAN;
UPDATE STATISTICS dbo.orders WITH FULLSCAN;
GO
6) Optional: Adjust compression and fill factor for write-heavy clustered index on dbo.lineitem
- Rationale:
- dbo.lineitem clustered index shows relatively high user_updates (3,125) versus seeks/scans, suggesting meaningful write activity.
- A moderate fill factor can mitigate page splits; PAGE compression can substantially reduce I/O on scans at the cost of CPU. Consider workload CPU headroom.
- This change is optional and should be evaluated in a performance window.
- Script (no key changes; only storage attributes). Note: This is an online operation in Developer/Enterprise editions.
USE tpch;
GO
-- Rebuild clustered index with PAGE compression and a moderate fill factor
ALTER INDEX [l_shipdate_ind] ON dbo.lineitem
REBUILD WITH (
DATA_COMPRESSION = PAGE,
FILLFACTOR = 95,
SORT_IN_TEMPDB = ON,
ONLINE = ON,
MAXDOP = 0
);
GO
If CPU pressure increases, consider ROW compression instead of PAGE: replace DATA_COMPRESSION = PAGE with ROW.
Additional Considerations
- Foreign keys and primary keys: No recommendation to change clustered keys or primary keys; therefore, no FK/PK drop-recreate actions are required.
- Maintenance impact: The proposed additional nonclustered indexes (on part, partsupp, customer) target read-mostly tables with effectively zero observed updates, minimizing DML overhead while delivering measurable query benefits.
- Index hygiene: After deployment, monitor sys.dm_db_index_usage_stats for 1–2 maintenance cycles. Any new indexes with near-zero seeks/scans and nontrivial updates should be reconsidered.
Index Tuning Recommendations Example #2 – SQLStorm
This is an example of index recommendations using example queries from the SQLStorm benchmarking project using the StackOverflow DBA (1GB) database.
The results were generated in just seconds using GPT-5, saving hours of effort for an experienced DBA or SQL Server database developer.
AI SQL Tuner Recommendations
Tuning goal: Index Tuning
Executive Summary (Top Priorities)
- Create two covering indexes on Posts to support the dominant predicates (PostTypeId + CreationDate) and per-user recency analytics (OwnerUserId + CreationDate). These will accelerate most CTEs and windowed queries and reduce heavy scans/reads.
- Add a composite covering index on PostHistory (PostId, PostHistoryTypeId, CreationDate) including (Comment, UserDisplayName) to speed frequent close/reopen analysis and MIN/MAX aggregations.
- Add an index on Users(Reputation DESC) including (Id, DisplayName, CreationDate) to satisfy top-user rankings and reputation threshold filters without scanning the clustered PK.
- Refine Votes indexes to better cover common aggregations by PostId and by UserId. Modify to include Id and PostId/BountyAmount where needed to enable narrow, covering scans for COUNT/SUM.
- After creating the above, drop/replace older overlapping Posts NCIs (IX_Posts_PostType_CreationDate_Owner and IX_Posts_CreationDate_Owner_Score_View) to reduce maintenance and duplication.
Detailed Prioritized Recommendations (with Scripts)
1) Posts: Cover PostTypeId + CreationDate filtering and per-user recency analytics
- Rationale: Most high-cost queries filter Posts by PostTypeId, CreationDate ranges, Score>0, or ClosedDate IS NULL, and use window functions partitioned by OwnerUserId ordered by CreationDate.
- Action A: Create a covering index for PostTypeId + CreationDate to serve WHERE clauses and ORDER BY/ROW_NUMBER on CreationDate, including common select list columns.
-- A1) Cover filters on PostTypeId + CreationDate and many projections
CREATE INDEX IX_Posts_PostType_CreationDate_Cover
ON dbo.Posts (PostTypeId, CreationDate DESC)
INCLUDE (Id, OwnerUserId, Score, ViewCount, AnswerCount, Title, AcceptedAnswerId, ClosedDate)
WITH (
FILLFACTOR = 97,
DATA_COMPRESSION = PAGE,
ONLINE = ON,
RESUMABLE = ON
);
- Action B: Create a covering index for per-user recent post analytics (partition/order by OwnerUserId, CreationDate).
-- A2) Per-user recent posts / ranking patterns
CREATE INDEX IX_Posts_OwnerUser_CreationDate_Cover
ON dbo.Posts (OwnerUserId, CreationDate DESC)
INCLUDE (Id, Title, Score, ViewCount, AnswerCount, PostTypeId, AcceptedAnswerId)
WITH (
FILLFACTOR = 97,
DATA_COMPRESSION = PAGE,
ONLINE = ON,
RESUMABLE = ON
);
2) PostHistory: Speed close/reopen lookups and aggregations
- Rationale: Frequent filters on PostHistoryTypeId IN (10,11), joins by PostId, and aggregations such as MIN(CreationDate) and COUNT per PostId.
- Action: Create/replace a covering composite index including creation date and descriptive columns.
-- B) Cover PostHistory by PostId + Type with ordering by CreationDate and includes
CREATE INDEX IX_PostHistory_PostId_Type_CreationDate
ON dbo.PostHistory (PostId, PostHistoryTypeId, CreationDate)
INCLUDE (Comment, UserDisplayName)
WITH (
FILLFACTOR = 97,
DATA_COMPRESSION = PAGE,
ONLINE = ON,
RESUMABLE = ON
);
3) Users: Accelerate reputation-based rankings and thresholds
- Rationale: Many queries rank and filter on Users.Reputation, returning Id, DisplayName, and CreationDate; current scans on clustered PK show high scan counts.
- Action: Add descending key on Reputation, including common columns.
-- C) Support ORDER BY Reputation DESC and filters (e.g., > 100, > 1000)
CREATE INDEX IX_Users_Reputation_Desc
ON dbo.Users (Reputation DESC)
INCLUDE (Id, DisplayName, CreationDate)
WITH (
FILLFACTOR = 98,
DATA_COMPRESSION = PAGE,
ONLINE = ON,
RESUMABLE = ON
);
4) Votes: Improve coverage for frequent aggregations
- Rationale: Heavy aggregations by PostId for vote counts (2/3) and by UserId for bounties and vote totals. Ensure indexes are covering for COUNT/SUM without needing lookups.
- Action A: Ensure PostId+VoteTypeId is covering for COUNT and join. If IX_Votes_PostId_VoteType exists without includes, replace with includes.
-- D1) Enhance Votes by PostId + VoteTypeId to cover COUNT(*), joins, and selective scans
CREATE INDEX IX_Votes_PostId_VoteType_Cover
ON dbo.Votes (PostId, VoteTypeId)
INCLUDE (Id, UserId, BountyAmount)
WITH (
DROP_EXISTING = ON, -- use if IX_Votes_PostId_VoteType exists; remove if creating anew
FILLFACTOR = 95,
DATA_COMPRESSION = PAGE,
ONLINE = ON,
RESUMABLE = ON
);
- Action B: Ensure UserId-side aggregations (totals, bounties) are covered. If IX_Votes_UserId_VoteType_Bounty exists but lacks PostId/Id in INCLUDE, replace with DROP_EXISTING.
-- D2) Enhance Votes by UserId for aggregations and joins
CREATE INDEX IX_Votes_UserId_VoteType_Bounty_Cover
ON dbo.Votes (UserId, VoteTypeId)
INCLUDE (BountyAmount, PostId, Id)
WITH (
DROP_EXISTING = ON, -- use if IX_Votes_UserId_VoteType_Bounty exists; remove if creating anew
FILLFACTOR = 95,
DATA_COMPRESSION = PAGE,
ONLINE = ON,
RESUMABLE = ON
);
5) Comments: Validate coverage by PostId (no change needed)
- Rationale: Current IX_Comments_PostId appropriately supports COUNT/GROUP BY PostId and joins. No action required; retain existing index to avoid unnecessary write overhead.
6) Badges: Validate current index (no change unless missing INCLUDE Id)
- Rationale: Aggregations by UserId and by Class are supported by IX_Badges_UserId_Class. To ensure narrow covering count on Id, an INCLUDE(Id) is optional. Only apply if COUNT(Id) hot spots are identified.
-- Optional: only if you observe residual lookups on Badges.Id during counts
CREATE INDEX IX_Badges_UserId_Class_Cover
ON dbo.Badges (UserId, Class)
INCLUDE (Id)
WITH (
DROP_EXISTING = ON,
FILLFACTOR = 98,
DATA_COMPRESSION = PAGE,
ONLINE = ON,
RESUMABLE = ON
);
7) De-duplicate older overlapping Posts indexes (after validation period)
- Rationale: The new Posts indexes supersede IX_Posts_PostType_CreationDate_Owner and IX_Posts_CreationDate_Owner_Score_View by providing broader coverage with fewer reads. Dropping them reduces write/maint overhead.
- Action: Drop after workload validation confirms stable plans on the new indexes.
-- E) Drop overlapping/less-effective Posts NCIs (perform after verifying new index usage)
DROP INDEX IX_Posts_PostType_CreationDate_Owner ON dbo.Posts;
DROP INDEX IX_Posts_CreationDate_Owner_Score_View ON dbo.Posts;
8) Maintenance notes (fillfactor & compression rationale)
- Fillfactor: 95–98 chosen based on append-heavy patterns (Posts, Votes, PostHistory) to mitigate page splits on hot prefixes (PostId/UserId/CreationDate) without bloating storage.
- Compression: PAGE compression on large fact-like tables (Posts, Votes, PostHistory, Badges) typically yields significant I/O reduction in aggregate and window queries on SQL Server 2022.
- Online/resumable: Enabled to minimize blocking and allow pausability during deployment on Developer/Enterprise editions.
Optional Enhancements (apply if workload patterns persist)
- Filtered index for recent Posts windows if the majority of analytics target the last year:
-- Optional: accelerate year-bound analytics while keeping index small CREATE INDEX IX_Posts_CreationDate_LastYear ON dbo.Posts (CreationDate DESC) INCLUDE (Id, OwnerUserId, Title, Score, ViewCount, AnswerCount, PostTypeId) WHERE CreationDate >= DATEADD(YEAR, -1, SYSUTCDATETIME()) WITH ( FILLFACTOR = 98, DATA_COMPRESSION = PAGE, ONLINE = ON, RESUMABLE = ON ); - Filtered index for PostHistory close/reopen types only:
-- Optional: restrict to close/reopen history to shrink index and speed scans CREATE INDEX IX_PostHistory_CloseReopen ON dbo.PostHistory (PostId, CreationDate) INCLUDE (Comment, UserDisplayName) WHERE PostHistoryTypeId IN (10, 11) WITH ( FILLFACTOR = 98, DATA_COMPRESSION = PAGE, ONLINE = ON, RESUMABLE = ON );
