Performance Audit Playbook


flowchart TD
    A["Start audit"] --> B["Phase 1<br/>Baseline and confidence"]
    B --> C{"Uptime and config<br/>support strong conclusions?"}
    C --> Y1([YES])
    C --> N1([NO])
    Y1 --> D["Trust cumulative DMVs more"]
    N1 --> E["Add limited-history disclaimer<br/>and favor point-in-time checks"]
    D --> F["Memory and waits"]
    E --> F
    F --> G{"Pressure or instability<br/>visible?"}
    G --> Y2([YES])
    G --> N2([NO])
    Y2 --> H["Correlate with I/O, queries,<br/>indexes, TempDB, and blocking"]
    N2 --> I["Validate files, stats,<br/>security, and growth settings"]
    H --> J["Compile report"]
    I --> J

    classDef yesNode fill:#1f3b2d,stroke:#73d13d,stroke-width:2px,color:#c0caf5,font-weight:bold;
    classDef noNode fill:#4a1f24,stroke:#db4b4b,stroke-width:2px,color:#c0caf5,font-weight:bold;
    class Y1,Y2 yesNode;
    class N1,N2 noNode;

Phase 1 | Instance Baseline

This phase establishes what can be trusted. Version determines available behavior and fixes. Uptime determines how much history DMVs and cumulative counters contain. Core configuration determines whether later symptoms come from workload problems, bad defaults, or both.

Instance and database context

This subsection captures the engine baseline in one row, then checks the per-database defaults that most often distort a production audit.

Capture the instance baseline

First action of any audit, before touching any cumulative DMV. It is typically triggered by scheduled health check, unplanned performance complaint, post-restart verification, or drift-detection sweep across instances. Single T-SQL session against the target instance, VIEW SERVER STATE required, strictly read-only, no restart or downtime implications. Anchor every later cumulative finding against the engine build, uptime-driven confidence boundary, CPU and memory posture, and the four configuration knobs that dominate CPU and memory behavior.

The query is deliberately wide: it mixes SERVERPROPERTY metadata, live process state from sys.dm_os_sys_info, and current configuration values from sys.configurations so that every downstream phase has a ready-made baseline to reference without running extra queries.

FieldSourceType / UnitMeaning
versionSERVERPROPERTY('ProductVersion')nvarchar, dotted buildFull engine build number (e.g. 16.0.4236.2 = SQL Server 2022 CU).
editionSERVERPROPERTY('Edition')nvarcharProduct edition — determines feature availability (Developer, Standard, Enterprise, Express).
patch_levelSERVERPROPERTY('ProductLevel')nvarcharService-pack or CU branch indicator — RTM, SP1, CTP, etc.
uptime_daysDATEDIFF(DAY, sys.dm_os_sys_info.sqlserver_start_time, GETDATE())integer, daysAge of in-memory DMV history since the last service start.
uptime_hoursDATEDIFF(HOUR, sys.dm_os_sys_info.sqlserver_start_time, GETDATE())integer, hoursFiner-grained uptime view for sub-day audits.
logical_cpussys.dm_os_sys_info.cpu_countintegerNumber of schedulers (logical processors) SQL Server sees.
physical_memory_mbsys.dm_os_sys_info.physical_memory_kb / 1024integer, MBTotal host RAM visible to SQL Server.
committed_mbsys.dm_os_sys_info.committed_kb / 1024integer, MBMemory currently committed by the SQL Server process.
target_mbsys.dm_os_sys_info.committed_target_kb / 1024integer, MBMemory the engine wants to commit — the ceiling it is currently working toward.
maxdopsys.configurations.value_in_use where name = 'max degree of parallelism'integerMaximum schedulers a single query may use. 0 = unlimited.
cost_threshold_for_parallelismsys.configurations.value_in_use where name = 'cost threshold for parallelism'integer, cost unitsEstimated plan cost above which a parallel plan is considered.
max_server_memory_mbsys.configurations.value_in_use where name = 'max server memory (MB)'bigint, MBUpper bound on buffer-pool memory. 2147483647 means effectively uncapped.
optimize_for_ad_hoc_workloadssys.configurations.value_in_use where name = 'optimize for ad hoc workloads'integer, 0/1When 1, the first execution of an ad hoc batch stores only a compiled-plan stub to reduce cache waste.

Capture the SQL Server build, uptime, CPU and memory posture, and the core configuration values that govern parallelism, memory growth, and ad hoc plan caching.

SELECT
    CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(50)) AS version,
    CAST(SERVERPROPERTY('Edition')        AS nvarchar(100)) AS edition,
    CAST(SERVERPROPERTY('ProductLevel')   AS nvarchar(20))  AS patch_level,
    DATEDIFF(DAY,  sqlserver_start_time, GETDATE()) AS uptime_days,
    DATEDIFF(HOUR, sqlserver_start_time, GETDATE()) AS uptime_hours,
    cpu_count AS logical_cpus,
    physical_memory_kb  / 1024 AS physical_memory_mb,
    committed_kb        / 1024 AS committed_mb,
    committed_target_kb / 1024 AS target_mb,
    CAST((SELECT value_in_use FROM sys.configurations WHERE name = 'max degree of parallelism')     AS int)    AS maxdop,
    CAST((SELECT value_in_use FROM sys.configurations WHERE name = 'cost threshold for parallelism') AS int)    AS cost_threshold_for_parallelism,
    CAST((SELECT value_in_use FROM sys.configurations WHERE name = 'max server memory (MB)')         AS bigint) AS max_server_memory_mb,
    CAST((SELECT value_in_use FROM sys.configurations WHERE name = 'optimize for ad hoc workloads')  AS int)    AS optimize_for_ad_hoc_workloads
FROM sys.dm_os_sys_info;
versioneditionpatch_leveluptime_daysuptime_hourslogical_cpusphysical_memory_mbcommitted_mbtarget_mbmaxdopcost_threshold_for_parallelismmax_server_memory_mboptimize_for_ad_hoc_workloads
16.0.4236.2Developer Edition (64-bit)RTM0516247321940227050521474836470

This instance is running SQL Server 2022 on a fresh uptime boundary: uptime_days = 0 with uptime_hours = 5. That immediately reduces the confidence of every cumulative DMV below. The configuration row exposes three hardening gaps before any workload analysis starts: maxdop = 0 on a 16-logical-CPU host, cost threshold for parallelism = 5, and max server memory (MB) left at the effectively-unlimited default 2147483647. optimize for ad hoc workloads = 0 becomes relevant again in Phase 9, where single-use ad hoc plans dominate cache memory. The memory column pair (committed_mb = 1940 vs target_mb = 22705) shows SQL Server still climbing toward its uncapped target after restart — useful context when Phase 2 reports a warm buffer pool but a low absolute footprint.

Cumulative phases are uptime-limited

The low uptime means Phases 3, 5, 6, 8, and 9 do not yet represent a full business cycle. Wait rankings, cached-plan rankings, and missing-index evidence captured here reflect only the last few hours of activity, which on this instance is dominated by backup and restore tests, TDE demo setup, and index-maintenance rehearsals.

Treat early outputs as point-in-time evidence

Use the current outputs as point-in-time evidence, and repeat every cumulative phase after a representative workload window before recommending lasting configuration changes. For instances that cannot wait, compare deltas between two snapshots rather than interpreting lifetime totals.

ColumnValueWatchMeaningImplication
uptime_days0DMV and cumulative counter history started today.Waits, cached-query rankings, and missing-index evidence are limited-history signals only.
uptime_days1-6DependsPartial history.Usable for point-in-time triage, weak for long-run tuning decisions.
uptime_days>= 7The instance has at least one week of history.Cumulative DMVs are usually much more representative.
uptime_days> 180DependsVery long uptime.Good history but some counters may be skewed by rare incidents from months ago; consider delta-sampling.
maxdop0❌ on hosts with > 8 logical CPUsA single query can use all available schedulers, subject to optimizer choice.Can amplify parallel overhead and distort wait patterns. Microsoft’s starting point is MAXDOP = 8 for most OLTP workloads.
maxdop1DependsParallelism disabled instance-wide.Only appropriate for specific workloads such as SharePoint; makes large analytic queries single-threaded.
maxdop2 to logical_cpus/2Bounded parallelism.Usually the healthy range for mixed OLTP/analytic workloads.
cost_threshold_for_parallelism5❌ on modern production systemsVery cheap statements can qualify for parallel plans.Common source of unnecessary CX* waits and worker pressure. Raise to 25-50 for most instances.
cost_threshold_for_parallelism25-50Only meaningfully expensive queries go parallel.Aligns with Microsoft’s modern guidance.
max_server_memory_mb2147483647SQL Server is effectively uncapped.The OS becomes the back-pressure mechanism instead of the configuration. Set an explicit cap leaving ~20% of RAM (minimum 4 GB) for the OS and non-SQL services.
max_server_memory_mbExplicit, leaves >= 4 GB for OSCap is tuned.OS has headroom for kernel, filesystem cache, and backup agents.
optimize_for_ad_hoc_workloads0DependsFirst execution of an ad hoc statement stores a full compiled plan.Usually acceptable on well-parameterized workloads, wasteful on ad hoc-heavy ones (see Phase 9).
optimize_for_ad_hoc_workloads1✅ on ad hoc-heavy instancesFirst execution stores only a compiled-plan stub.Reclaims cache memory from throwaway plans with no downside for reused queries.
committed_mb vs target_mbcommitted << targetDependsSQL Server has room to grow under current workload.Normal shortly after restart. Watch the ratio close if committed stalls well below target over time.
committed_mb vs target_mbcommitted ≈ targetBuffer pool is fully warmed.Expected steady-state on busy instances.

Review database inventory and risky defaults

Directly after the instance baseline, before any workload-level query. It is typically triggered by first audit of an unfamiliar instance, post-migration validation, or investigation of why a specific database behaves differently from the rest. Single T-SQL session, VIEW ANY DEFINITION or sysadmin, read-only against sys.databases, no locking risk. Expose the per-database defaults (recovery model, compatibility level, RCSI, auto-shrink, auto-stats) that most frequently distort cumulative DMVs and mask or amplify later findings.

sys.databases is the authoritative catalog view for the settings below. Every row on the instance — including system databases, offline databases, and AG secondaries in their RESTORING state — appears once, which makes this the correct inventory source rather than DATABASEPROPERTYEX.

FieldSourceType / UnitMeaning
namesys.databases.namesysnameLogical database name.
state_descsys.databases.state_descnvarchar(60)Operational state — ONLINE, RESTORING, RECOVERING, RECOVERY_PENDING, SUSPECT, EMERGENCY, OFFLINE.
recovery_model_descsys.databases.recovery_model_descnvarchar(60)SIMPLE, BULK_LOGGED, or FULL. Controls log-reuse and backup-chain behavior.
compatibility_levelsys.databases.compatibility_leveltinyintOptimizer/cardinality-estimator version (100 = 2008, 150 = 2019, 160 = 2022).
rcsisys.databases.is_read_committed_snapshot_onbit1 = READ COMMITTED uses row versions instead of shared locks.
auto_shrinksys.databases.is_auto_shrink_onbit1 = automatic shrink enabled.
auto_statssys.databases.is_auto_create_stats_onbit1 = engine may auto-create single-column stats on demand.
auto_update_statssys.databases.is_auto_update_stats_onbit1 = engine may auto-refresh stats when modification thresholds are hit.

Inventory database state, recovery model, compatibility level, RCSI, and auto-statistics defaults across the instance.

SELECT
    name,
    state_desc,
    recovery_model_desc,
    compatibility_level,
    is_read_committed_snapshot_on AS rcsi,
    is_auto_shrink_on             AS auto_shrink,
    is_auto_create_stats_on       AS auto_stats,
    is_auto_update_stats_on       AS auto_update_stats
FROM sys.databases
ORDER BY name;
namestate_descrecovery_model_desccompatibility_levelrcsiauto_shrinkauto_statsauto_update_stats
codex_tde_demoONLINEFULL1600011
masterONLINESIMPLE1600011
modelONLINEFULL1600011
msdbONLINESIMPLE1600011
stoxxONLINEFULL1600011
stoxx_backupONLINEFULL1600011
stoxx_dbONLINEFULL1601011
tempdbONLINESIMPLE1600011

Database-level defaults are mostly healthy: every database is online, compatibility_level = 160 (SQL Server 2022), auto_shrink = 0 everywhere, and automatic statistics are enabled universally. The operational questions are narrower and database-specific. Three user databases (stoxx, stoxx_backup, stoxx_db) are in FULL recovery, which means log-reuse findings in Phase 10 must be judged in that context — and codex_tde_demo (also FULL) is relevant because a TDE demo database that is never log-backed will accumulate log space indefinitely. stoxx_db is the only database with rcsi = 1, meaning its READ COMMITTED readers use row versions instead of shared locks; that distinction matters when the same audit window touches multiple databases with different concurrency semantics. Every other database still uses lock-based READ COMMITTED, which is the implicit default and deserves an explicit decision on busy mixed-workload systems rather than being left by accident.

ColumnValueWatchMeaningImplication
state_descONLINEDatabase is available for normal access.Baseline healthy state.
state_descRESTORING / RECOVERINGDependsTransient recovery state.Expected during restores and AG seeding; alarming outside those windows.
state_descSUSPECT / RECOVERY_PENDING / EMERGENCYDatabase is damaged or cannot recover.Availability is broken; performance findings are secondary.
state_descOFFLINEDependsExplicitly taken offline.Verify the reason; may mask capacity planning.
compatibility_level160SQL Server 2022 database compatibility level.Modern optimizer and IQP features can be used.
compatibility_level<= 140DependsSQL Server 2017 or earlier CE.Consider testing plan regressions before raising; some tuning advice below assumes modern CE.
rcsi0DependsDefault read-committed behavior still uses locking, not row-versioning.On busy OLTP systems, reader/writer blocking deserves special attention.
rcsi1DependsREAD COMMITTED uses row versions.Reduces reader/writer blocking, but increases tempdb version-store use.
auto_shrink0Auto-shrink is disabled.Avoids shrink/regrow churn and fragmentation.
auto_shrink1Database can shrink itself automatically.Strong operational anti-pattern; disable immediately and never pair with auto-grow.
auto_stats / auto_update_stats1Automatic statistics creation and refresh are enabled.Sensible default for most workloads.
auto_stats / auto_update_stats0Automatic creation or refresh is suppressed.Optimizer flies blind; justify only in tightly managed statistics-maintenance regimes.
recovery_model_descFULLDependsFull recovery chain expected.Log reuse must be interpreted with backup and restore policy in mind.
recovery_model_descSIMPLEDependsLog truncates on checkpoint.No point-in-time recovery; verify business RPO still permits this.
recovery_model_descBULK_LOGGEDDependsMinimally-logged bulk ops.Usually only during bulk loads; leaving it permanently active is a recoverability risk.

Phase 2 | Memory and Buffer Pool

This phase checks whether the instance is actually under memory pressure, how the buffer pool is distributed, and whether any query is currently waiting for a memory grant.

Working-set and memory-pressure checks

These queries answer three different questions: who owns the buffer pool, whether page churn is high, and whether any query is blocked waiting for workspace memory.

Measure buffer-pool ownership by database

First check of Phase 2, before interpreting PLE or wait data. It is typically triggered by suspected memory pressure, unexplained PAGEIOLATCH_* waits, multi-database instance where one workload may be starving another. Single T-SQL session, VIEW SERVER STATE, read-only. On busy instances sys.dm_os_buffer_descriptors can be expensive to scan; run during a calm window when possible. Expose how buffer-pool memory is distributed across databases so that later PLE and wait findings can be attributed to the right workload.

sys.dm_os_buffer_descriptors exposes one row per cached 8 KB data page. Grouping by database_id aggregates pages into a per-database footprint, and multiplying by 8 KB (COUNT(*) * 8 / 1024) converts pages to MB.

FieldSourceType / UnitMeaning
db_nameDB_NAME(sys.dm_os_buffer_descriptors.database_id)sysnameDatabase the cached page belongs to. NULL = system-level allocations not tied to a user database (free pages, resource DB, internal).
buffer_pool_mbCOUNT(*) * 8 / 1024 over sys.dm_os_buffer_descriptorsinteger, MBTotal cached pages rolled up to megabytes (1 page = 8 KB).

Measure how much of the buffer pool is currently occupied by each database.

SELECT
    DB_NAME(database_id) AS db_name,
    COUNT(*) * 8 / 1024  AS buffer_pool_mb
FROM sys.dm_os_buffer_descriptors
GROUP BY database_id
ORDER BY buffer_pool_mb DESC;
db_namebuffer_pool_mb
stoxx480
NULL18
stoxx_backup16
msdb8
tempdb8
codex_tde_demo8
model_msdb4
stoxx_db3
model_replicatedmaster3
master2
model0

stoxx dominates the useful cache at 480 MB — expected since it is the primary workload database on this instance. stoxx_backup appearing at 16 MB is the residue of recent backup/restore rehearsals rather than an active workload. tempdb at 8 MB is noticeably quiet compared to the earlier audit window, matching the Phase 7 finding that tempdb has been reset to minimal 8 MB files. The eight other databases each hold single-digit MB and together account for under 60 MB, so there is no multi-database cache fight to diagnose. The important context is absolute scale: the instance is using roughly 550 MB of data cache on a host with 24 GB of RAM and an uncapped memory target of 22705 MB. The buffer pool has not yet warmed — not because of pressure, but because only a fraction of the working set has been touched since the 5-hour-old restart.

ColumnValueWatchMeaningImplication
db_nameUser database dominatesDependsMost cache belongs to the main workload database.Usually normal on single-tenant instances.
db_nametempdb unusually large (> ~20% of total)DependsTemp objects, spills, or version-store activity are consuming cache.Correlate with Phase 7 before calling it a problem.
db_nameNULL smallMinor internal or unassigned buffer usage.Normal.
db_nameNULL large and growingDependsUnusual system-level buffer use.Check for DBCC operations, resource database activity, or memory clerks.
buffer_pool_mbConcentrated in one DB with healthy PLEWorking set is stable in memory.Not a standalone concern.
buffer_pool_mbOne DB dominates while others thrash❌ context-dependentOne workload may be flushing others from cache.Validate with low PLE, scans, and I/O waits before acting.
buffer_pool_mbTotal << target_mb shortly after restartDependsBuffer pool is still warming up.Normal; revisit once committed_mb approaches target_mb.
buffer_pool_mbTotal << target_mb after steady-state uptimeDependsWorking set may be genuinely small, or internal caches are consuming memory.Inspect memory clerks in sys.dm_os_memory_clerks to attribute usage.

Check Page Life Expectancy by buffer node

Immediately after the buffer-pool breakdown, as the second memory-pressure signal. It is typically triggered by suspected memory pressure, reports of slow queries with high physical reads, planning NUMA layout changes, or validating that the buffer pool has warmed. Single T-SQL session, VIEW SERVER STATE, read-only. The query is trivial. Quantify how long a data page survives in the buffer pool before being evicted, and confirm the value is consistent across NUMA nodes.

sys.dm_os_performance_counters exposes both the instance-aggregate Buffer Manager PLE and one row per NUMA buffer node under Buffer Node. Comparing the two is essential because a healthy aggregate value can hide one starved node on multi-socket hardware.

FieldSourceType / UnitMeaning
object_namesys.dm_os_performance_counters.object_namenchar(128)Performance-object name (e.g. SQLServer:Buffer Manager or SQLServer:Buffer Node). Trailing whitespace is common and harmless.
counter_namesys.dm_os_performance_counters.counter_namenchar(128)Name of the specific counter — here locked to Page life expectancy.
instance_namesys.dm_os_performance_counters.instance_namenchar(128)NUMA node index (000, 001, …) for the Buffer Node object; empty for the aggregate.
ple_secondssys.dm_os_performance_counters.cntr_valuebigint, secondsAverage residency time of pages in that buffer pool.

Check how long pages remain in memory at both the aggregate and per-node level.

SELECT
    object_name,
    counter_name,
    instance_name,
    cntr_value AS ple_seconds
FROM sys.dm_os_performance_counters
WHERE counter_name = 'Page life expectancy'
  AND object_name LIKE '%Buffer%'
ORDER BY object_name, instance_name;
object_namecounter_nameinstance_nameple_seconds
SQLServer:Buffer ManagerPage life expectancy18103
SQLServer:Buffer NodePage life expectancy00018103

This is a healthy point-in-time PLE result. 18103 seconds is almost exactly five hours — effectively the full uptime of the instance — which means no meaningful page eviction has occurred since startup. Aggregate and node 000 PLE are identical, which is the expected shape on a single-NUMA-node container host. On this instance, low PLE is categorically not the bottleneck to chase first. The five-hour number should be re-interpreted once the instance has a full business cycle of uptime; a PLE value that simply tracks uptime is weaker evidence of memory health than one that plateaus after workload reaches a steady state.

ColumnValueWatchMeaningImplication
ple_secondsHigh and stablePages remain in memory for a long time.Memory pressure is unlikely right now.
ple_secondsTracks uptime linearly on a fresh instanceDependsBuffer pool has never been forced to evict.Value is informative but reflects lack of pressure rather than proven resilience; re-check after steady-state uptime.
ple_secondsPersistently low relative to 300 * buffer_pool_gb / 4Pages are being evicted quickly.Correlate with scans, I/O, grants, and memory cap before concluding root cause.
ple_secondsSudden dropA query, maintenance operation, or process restart evicted large portions of cache.Capture the time and correlate with workload events.
instance_nameAggregate and node values similarNo obvious node skew.NUMA-local pressure is not visible.
instance_nameOne node much lower than othersOne buffer node is under disproportionate churn.Investigate NUMA locality, scheduler skew, and workload placement.
instance_nameOnly node 000 presentDependsSingle NUMA node (container, laptop, small VM).Aggregate vs node comparison is meaningless; just read the single value.

Check pending memory grants

As the third memory check, directly after PLE. It is typically triggered by users report queries “stuck before running”, dashboards show RESOURCE_SEMAPHORE waits, investigation of seemingly idle sessions that consume workspace memory. Single T-SQL session, VIEW SERVER STATE, read-only, point-in-time snapshot of the live grant queue. Determine whether any query is currently queued for workspace memory rather than executing, which distinguishes grant starvation from buffer-pool pressure.

sys.dm_exec_query_memory_grants is the authoritative live view of workspace-memory requests. Rows where grant_time IS NOT NULL are already running; rows where grant_time IS NULL are queued behind the memory-grant semaphore.

FieldSourceType / UnitMeaning
session_idsys.dm_exec_query_memory_grants.session_idsmallintThe session whose query is waiting for (or holding) a grant.
requested_mbsys.dm_exec_query_memory_grants.requested_memory_kb / 1024integer, MBWorkspace memory the optimizer requested for sorts, hashes, parallelism, and bulk operators.
granted_mbsys.dm_exec_query_memory_grants.granted_memory_kb / 1024integer, MBWorkspace memory actually granted so far. 0 while the request is queued.
wait_secsys.dm_exec_query_memory_grants.wait_time_ms / 1000.0decimal, secondsHow long this request has already been queued.

Check whether any query is currently waiting for a memory grant rather than executing.

SELECT
    session_id,
    requested_memory_kb / 1024 AS requested_mb,
    granted_memory_kb   / 1024 AS granted_mb,
    wait_time_ms        / 1000.0 AS wait_sec
FROM sys.dm_exec_query_memory_grants
WHERE grant_time IS NULL;
session_idrequested_mbgranted_mbwait_sec

No query is currently queued for memory. That is the desired result. It means there is no visible RESOURCE_SEMAPHORE style grant backlog at capture time. This has to be interpreted alongside Phase 3: if RESOURCE_SEMAPHORE appears prominently there, the audit window missed live grant starvation and a repeat capture during peak hours is warranted. On a pressured system, even a few waiting rows matter because grant starvation can stall otherwise efficient queries while the cached plan keeps them in a runnable-but-blocked state.

ColumnValueWatchMeaningImplication
Result setNo rowsNo query is waiting for a memory grant right now.Memory grants are not a live bottleneck.
Result setA few transient rowsDependsQueries briefly queued.Often normal on busy OLTP instances; confirm wait_sec is low.
Result setMany rows, growing over repeated capturesPersistent grant backlog.Investigate memory cap, DOP, cardinality estimates, and Resource Governor workload groups.
requested_mbLarge and growing❌ if paired with waitsQuery wants a large workspace memory allocation.Investigate joins, sorts, estimates, DOP, and stats quality.
granted_mb0 with waiting rowGrant not yet issued.Query is blocked before execution can proceed.
granted_mb< requested_mb on running queryDependsPartial grant; SQL Server trimmed the ask.May cause operator spills — correlate with tempdb internal usage (Phase 7).
wait_sec< 1 secFast-draining queue.Not a bottleneck.
wait_secIncreasing over consecutive capturesThe queue is not draining quickly.Correlate with RESOURCE_SEMAPHORE, memory cap, and expensive parallel plans.

Phase 3 | Wait Statistics

Wait stats are instance-level evidence about where SQL Server has spent time waiting for resources. They do not identify the root cause by themselves. Use them to choose the next branch of investigation, then confirm with the phase that matches the dominant wait family.


flowchart LR
    W["Dominant wait family"] --> W1["LCK_M_* (S/U/X/IX/SCH_S/SCH_M)"]
    W --> W2["CXPACKET / CXCONSUMER / CXSYNC_PORT"]
    W --> W3["PAGEIOLATCH_SH / EX / UP"]
    W --> W4["PAGELATCH_SH / EX / UP"]
    W --> W5["WRITELOG"]
    W --> W6["RESOURCE_SEMAPHORE"]
    W --> W7["SOS_SCHEDULER_YIELD + high signal_sec"]
    W --> W8["ASYNC_NETWORK_IO"]
    W --> W9["BACKUPTHREAD / BACKUPIO"]

    W1 --> P8["Phase 8 \u2014 blocking chain + head blocker"]
    W2 --> P1["Phase 1 \u2014 MAXDOP, cost threshold, plan shapes"]
    W3 --> P4["Phase 4 \u2014 file latency + scans"]
    W4 --> P7["Phase 7 \u2014 TempDB PFS/GAM contention"]
    W5 --> P10["Phase 10 \u2014 log file location + flush behavior"]
    W6 --> P2["Phase 2 \u2014 memory grants + memory cap"]
    W7 --> P1
    W8 --> C["Client read speed + result-set width"]
    W9 --> M["Backup schedule \u2014 expected during window"]

Top cumulative waits

Capture the top cumulative waits

After the baseline and memory phases, before diving into I/O, query, or blocking detail. It is typically triggered by any performance investigation where the root cause is not already known; cumulative triage after an incident; validating the effect of a configuration change after a full workload cycle. Single T-SQL session, VIEW SERVER STATE, read-only. Cumulative since last restart or explicit DBCC SQLPERF('sys.dm_os_wait_stats', CLEAR). Rank actionable waits and choose the next investigative branch. Not a root cause by itself — wait stats point to the family of bottleneck, which then has to be confirmed with the matching detail phase.

Short-uptime cumulative bias

Wait statistics are cumulative since startup, and this instance restarted approximately five hours before the capture window. Short uptime and recent admin activity can dominate the top rows; the numbers below reflect startup work, index-maintenance rehearsals, backup tests, and TDE demo setup rather than a production business cycle.

Use waits as a branch selector, not a verdict

Use the top waits to choose where to investigate next, not as a standalone verdict. If uptime is short, repeat this phase after a full workload window or compare deltas between two sys.dm_os_wait_stats snapshots instead of lifetime totals. For durable longitudinal wait stats, use Query Store’s sys.query_store_wait_stats view, which survives plan-cache churn and restarts.

FieldSourceType / UnitMeaning
wait_typesys.dm_os_wait_stats.wait_typenvarchar(60)Engine-defined wait type name (e.g. LCK_M_U, CXPACKET, PAGEIOLATCH_SH).
wait_secsys.dm_os_wait_stats.wait_time_ms / 1000.0decimal, secondsTotal cumulative wait time in seconds, including signal time.
signal_secsys.dm_os_wait_stats.signal_wait_time_ms / 1000.0decimal, secondsTime between a resource becoming available and the task being scheduled on a CPU. High share → scheduler / CPU pressure.
waiting_countsys.dm_os_wait_stats.waiting_tasks_countbigintNumber of tasks that have contributed to this wait type since startup.
pct100.0 * wait_time_ms / SUM(wait_time_ms) OVER ()decimal, %Share of cumulative wait time among the non-excluded wait types.

Rank the most significant cumulative waits after excluding common idle and housekeeping waits.

WITH waits AS (
    SELECT
        wait_type,
        wait_time_ms / 1000.0 AS wait_sec,
        signal_wait_time_ms / 1000.0 AS signal_sec,
        waiting_tasks_count AS waiting_count,
        100.0 * wait_time_ms / SUM(wait_time_ms) OVER () AS pct
    FROM sys.dm_os_wait_stats
    WHERE wait_type NOT IN (
        'SLEEP_TASK', 'LAZYWRITER_SLEEP', 'WAITFOR', 'BROKER_RECEIVE_WAITFOR',
        'BROKER_EVENTHANDLER', 'CLR_AUTO_EVENT', 'CLR_MANUAL_EVENT',
        'DISPATCHER_QUEUE_SEMAPHORE', 'XE_DISPATCHER_WAIT', 'DIRTY_PAGE_POLL',
        'HADR_FILESTREAM_IOMGR_IOCOMPLETION', 'SP_SERVER_DIAGNOSTICS_SLEEP',
        'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP', 'QDS_CLEANUP_STALE_QUERIES_TASK_MAIN_LOOP_SLEEP',
        'QDS_ASYNC_QUEUE', 'SQLTRACE_INCREMENTAL_FLUSH_SLEEP', 'CHECKPOINT_QUEUE',
        'FT_IFTS_SCHEDULER_IDLE_WAIT', 'XE_TIMER_EVENT', 'LOGMGR_QUEUE',
        'REQUEST_FOR_DEADLOCK_SEARCH', 'RESOURCE_QUEUE', 'SERVER_IDLE_CHECK',
        'SQLTRACE_BUFFER_FLUSH', 'WAIT_XTP_OFFLINE_CKPT_NEW_LOG', 'BROKER_TO_FLUSH',
        'BROKER_TASK_STOP', 'DBMIRRORING_CMD', 'PREEMPTIVE_OS_PIPEOPS',
        'PREEMPTIVE_XE_GETTARGETSTATE', 'ONDEMAND_TASK_QUEUE',
        'SOS_WORK_DISPATCHER', 'PWAIT_EXTENSIBILITY_CLEANUP_TASK'
    )
      AND waiting_tasks_count > 0
)
SELECT TOP (10)
    wait_type,
    CAST(wait_sec AS decimal(18,1)) AS wait_sec,
    CAST(signal_sec AS decimal(18,1)) AS signal_sec,
    waiting_count,
    CAST(pct AS decimal(10,2)) AS pct
FROM waits
ORDER BY wait_sec DESC;
wait_typewait_secsignal_secwaiting_countpct
LCK_M_U71.00.01142.27
BACKUPTHREAD16.20.03849.65
BACKUPIO15.20.262329.06
PARALLEL_REDO_WORKER_WAIT_WORK8.70.415515.19
STARTUP_DEPENDENCY_MANAGER8.60.0905.14
LCK_M_X5.90.093.54
PREEMPTIVE_OS_AUTHENTICATIONOPS4.40.041482.64
LCK_M_S3.50.01072.07
SLEEP_DBSTARTUP3.50.0342.06
MEMORY_ALLOCATION_EXT2.90.05678171.71

The raw ranking is dominated by restart aftermath and recent backup / restore rehearsals, not by a long-lived production pattern. LCK_M_U at 42.27% comes from a handful of demo sessions (waiting_count = 11) rather than a chronic hot row, which is exactly the distortion the short-uptime warning above is meant to flag. BACKUPTHREAD and BACKUPIO together account for almost 19% — this is the signature of the stoxx_backup rehearsals run against the instance earlier today and is expected to disappear once the backup window closes. PARALLEL_REDO_WORKER_WAIT_WORK and STARTUP_DEPENDENCY_MANAGER are both startup-time signals that will fall out of the top 10 on any instance with normal uptime. The absence of CXPACKET, CXSYNC_PORT, and CXCONSUMER from the top 10 is notable given the unhealthy parallelism configuration from Phase 1 (MAXDOP = 0, cost threshold = 5); it reinforces that the current wait ranking is backup- and lock-dominated, not workload-dominated, and must not be used as a parallelism tuning verdict. The near-zero signal_sec across every row confirms that CPU scheduling is not the primary driver — all of these are resource waits, not runnable-queue waits.

ColumnValueWatchMeaningImplication
wait_typeLCK_M_*DependsLock waits. The exact suffix indicates lock mode (S, U, X, IX, SCH_S, SCH_M).Correlate with Phase 8 and identify the head blocker before tuning anything else.
wait_typeCXPACKET, CXCONSUMER, CXSYNC_PORTDependsParallel exchange and synchronization waits.Validate with MAXDOP, cost threshold, data skew, and actual parallel query plans.
wait_typePAGEIOLATCH_SH / EX / UP❌ if sustainedData-page I/O waits.Jump to Phase 4 and buffer-pool checks.
wait_typePAGELATCH_SH / EX / UPDependsIn-memory page latch contention (often tempdb PFS/GAM/SGAM or hot b-tree pages).Jump to Phase 7 for TempDB, or investigate hot-spot page design.
wait_typeWRITELOG❌ if sustainedFlush to log file.Inspect Phase 4 log-file latency and commit patterns; consider batching.
wait_typeRESOURCE_SEMAPHOREWorkspace memory grants are queuing.Return to Phase 2 grant check; review memory cap and estimated grant sizes.
wait_typeSOS_SCHEDULER_YIELDDependsCooperative scheduler yields; usually CPU-bound.Confirm CPU saturation; check parallelism and aggressive query cost.
wait_typeASYNC_NETWORK_IODependsClient is slow to consume rows.Investigate client-side paging, result-set width, or network latency.
wait_typeBACKUPTHREAD / BACKUPIODependsBackup workers in flight.Expected during backup windows; problematic only when saturating the I/O path.
signal_secSmall fraction of wait_sec✅ relative to CPUMost wait time is resource wait, not scheduler wait.CPU starvation is not the primary interpretation of this snapshot.
signal_secMore than ~25% of total waitRunnable tasks are spending a high share of time waiting to get CPU.Investigate CPU pressure, runnable queues, and aggressive parallelism.
pctOne family dominatesDependsA small number of waits consume most cumulative time.Use that family to choose the next investigative phase.
pctNo single family above ~15%DependsBroad, diffuse waits.Common on healthy instances; switch to delta sampling or Query Store query_store_wait_stats for sharper signal.

Phase 4 | I/O Performance

I/O latency determines how expensive physical reads and writes are when the buffer pool cannot absorb the workload. The goal in this phase is not just to find slow files, but to separate storage latency from query-shape problems such as scans, spills, or unnecessary writes.

File-level latency

Measure average read and write stall by file

After Phase 3 when waits suggest I/O (PAGEIOLATCH_*, WRITELOG, IO_COMPLETION, BACKUPIO), or as routine Phase 4 triage. It is typically triggered by users report slow queries under cold cache, dashboards show rising physical reads, storage migration validation, pre- and post-change comparison when moving a database to new storage. Single T-SQL session, VIEW SERVER STATE, read-only. sys.dm_io_virtual_file_stats is lightweight and safe at any time; results are cumulative since SQL Server start or file creation. Separate storage latency from query-shape problems by measuring average read and write stall per file, then attributing slow storage to specific data or log paths.

Cumulative averages are excellent for identifying persistently bad storage but weaker for short spikes. For intermittent storage issues, capture two snapshots with a timed delay and compute delta averages instead.

FieldSourceType / UnitMeaning
db_nameDB_NAME(sys.dm_io_virtual_file_stats.database_id)sysnameDatabase owning the file.
file_typesys.master_files.type_descnvarchar(60)ROWS, LOG, FILESTREAM, or FULLTEXT.
physical_namesys.master_files.physical_namenvarchar(260)Absolute file path on the host OS.
num_of_readssys.dm_io_virtual_file_stats.num_of_readsbigintNumber of read I/Os issued against this file since the last engine restart.
num_of_writessys.dm_io_virtual_file_stats.num_of_writesbigintNumber of write I/Os issued against this file since the last engine restart.
avg_read_msio_stall_read_ms * 1.0 / num_of_readsdecimal, msAverage stall per read I/O — wall-clock time between issue and completion.
avg_write_msio_stall_write_ms * 1.0 / num_of_writesdecimal, msAverage stall per write I/O.
size_mbsize_on_disk_bytes / 1024.0 / 1024.0decimal, MBCurrent on-disk file size.

Measure cumulative average read and write latency per database file.

SELECT TOP (10)
    DB_NAME(fs.database_id) AS db_name,
    f.type_desc AS file_type,
    f.physical_name,
    fs.num_of_reads,
    fs.num_of_writes,
    CAST(CASE WHEN fs.num_of_reads  > 0
              THEN fs.io_stall_read_ms  * 1.0 / fs.num_of_reads  END AS decimal(18,2)) AS avg_read_ms,
    CAST(CASE WHEN fs.num_of_writes > 0
              THEN fs.io_stall_write_ms * 1.0 / fs.num_of_writes END AS decimal(18,2)) AS avg_write_ms,
    CAST(fs.size_on_disk_bytes / 1024.0 / 1024.0 AS decimal(18,2)) AS size_mb
FROM sys.dm_io_virtual_file_stats(NULL, NULL) AS fs
JOIN sys.master_files AS f
  ON fs.database_id = f.database_id
 AND fs.file_id     = f.file_id
ORDER BY (fs.io_stall_read_ms + fs.io_stall_write_ms) DESC;
db_namefile_typephysical_namenum_of_readsnum_of_writesavg_read_msavg_write_mssize_mb
stoxxROWS/var/opt/mssql/data/stoxx.mdf4865813110.500.28712.00
stoxxLOG/var/opt/mssql/data/stoxx_log.ldf414922360.520.271032.00
stoxx_backupROWS/var/opt/mssql/data/stoxx_backup.mdf27570.430.14712.00
msdbROWS/var/opt/mssql/data/MSDBData.mdf197660.280.3315.31
masterLOG/var/opt/mssql/data/mastlog.ldf121950.250.322.00
masterROWS/var/opt/mssql/data/master.mdf77540.480.464.69
tempdbLOG/var/opt/mssql/data/templog.ldf71300.290.428.00
codex_tde_demoROWS/var/opt/mssql/data/codex_tde_demo.mdf70550.400.428.00
stoxx_dbROWS/var/opt/mssql/data/stoxx_db_Primary.mdf21220.240.00128.00
stoxx_backupLOG/var/opt/mssql/data/stoxx_backup_log.ldf471120.190.221032.00

The storage profile is excellent. Every file in the top 10 shows average read and write latency under 1 ms, with the busiest file (stoxx.mdf) at 0.50 ms read and 0.28 ms write across 48658 reads. The stoxx_log.ldf is also well under the 5 ms commit-latency threshold that would normally prompt log-disk investigation. TempDB data files are conspicuously absent from this top 10 — a sharp contrast with the earlier audit window where tempdb dominated; the current run has TempDB reset to eight minimal 8 MB files (see Phase 7), so there has simply not been enough TempDB I/O to rank. There is no storage-latency evidence here that would justify blaming disk for any current performance finding. The read/write ratio on stoxx.mdf (48658 reads vs 1311 writes) matches a read-heavy analytical workload pattern, which is consistent with the 5-hour post-restart warm-up rather than an OLTP commit loop.

ColumnValueWatchMeaningImplication
avg_read_ms< 5 msPhysical reads return quickly.Local SSD or flash-backed SAN. Storage is not the bottleneck.
avg_read_ms5-10 msDependsTypical spinning or hybrid SAN.Acceptable for most workloads; still correlate with scan patterns.
avg_read_ms> 20 msSlow reads.Correlate with PAGEIOLATCH_*, investigate queue depth, path failover, noisy neighbors.
avg_write_ms< 5 ms on ROWSWrites are healthy.Good signal for data throughput.
avg_write_ms< 5 ms on LOGCommit latency is healthy.No synchronous AG, no storage back-pressure on log.
avg_write_ms> 10 ms on LOGLog commits are slow.Investigate log disk, synchronous replicas, flush behavior, or virtual-log-file explosion.
avg_write_msMuch higher than read latencyDependsWrite path is slower than read path.Common on cloud storage with write-through caches; confirm against baseline.
file_typeLOGDependsSequential write-heavy file.Judge it mainly by write latency, not read latency.
file_typeROWSDependsData file handling mixed reads and writes.Correlate with query shape and cache behavior.
num_of_readsNear zero on ROWSDependsFile has never been warmed.Normal post-restart or for rarely-touched databases.
num_of_writesNear zero on LOGDependsNo commits.Database is read-only or idle.

Phase 5 | Expensive Cached Statements

This phase ranks cached statements by cumulative CPU and logical reads. It is useful for triage, but it is not a substitute for workload history. sys.dm_exec_query_stats only covers statements that are in cache and resets when plans leave cache or the instance restarts.

Cache-based triage

Rank cached statements by cumulative CPU and logical reads

After the baseline confidence check, wait analysis, and I/O check have narrowed the investigation to query-level cost. It is typically triggered by need a shortlist of candidate statements to tune, confirm whether a reported slow query is cached, or rank workload hotspots when Query Store is unavailable. Single T-SQL session, VIEW SERVER STATE, read-only. CROSS APPLY sys.dm_exec_sql_text can be mildly expensive on a very large plan cache; acceptable in any normal audit window. Rank cached statements by cumulative CPU with logical-read and execution-count context so that single-shot monster statements are not mistaken for chronic hotspots.

sys.dm_exec_query_stats is only useful while a plan is in cache. Plans age out under cache pressure or when recompiled, and the DMV resets on restart or when DBCC FREEPROCCACHE is run. For durable hotspot tracking across plan churn, Query Store (sys.query_store_query_text, sys.query_store_runtime_stats) is the better source.

FieldSourceType / UnitMeaning
execution_countsys.dm_exec_query_stats.execution_countbigintNumber of executions since the plan entered cache.
total_cpu_mstotal_worker_time / 1000.0decimal, msCumulative CPU time across all executions (microseconds in DMV).
avg_cpu_mstotal_worker_time / execution_count / 1000.0decimal, msMean CPU time per execution.
total_logical_read_mbtotal_logical_reads * 8.0 / 1024decimal, MBCumulative pages read from the buffer pool, converted from 8 KB pages.
avg_logical_read_mbtotal_logical_reads * 8.0 / 1024 / execution_countdecimal, MBMean logical reads per execution.
total_elapsed_mstotal_elapsed_time / 1000.0decimal, msCumulative wall-clock time. Difference between CPU and elapsed exposes waits.
database_nameDB_NAME(plan_attributes.dbid) with fallback to DB_NAME(sql_text.dbid)sysnameDatabase context recorded in the cached plan.
query_textFirst 140 chars of sys.dm_exec_sql_text.text, newlines collapsednvarcharTruncated statement for triage.

Uptime-bound cache ranking

This instance has uptime_days = 0 and ~5 hours of uptime, so the ranking below is not representative of a normal production business cycle. Every top row reflects activity since the most recent restart; statements that ran earlier today on the previous process are gone.

Validate with Query Store before tuning

Use this output to understand what happened since the restart, then validate long-lived hotspots with Query Store and application-level workload context before tuning. sys.query_store_runtime_stats survives plan-cache churn and restarts and is the authoritative source for recurring workload patterns.

Rank cached statements by cumulative CPU time, with logical-read and execution-count context.

SELECT TOP (5)
    qs.execution_count,
    CAST(qs.total_worker_time   / 1000.0                                AS decimal(18,2)) AS total_cpu_ms,
    CAST(qs.total_worker_time   / NULLIF(qs.execution_count, 0) / 1000.0 AS decimal(18,2)) AS avg_cpu_ms,
    CAST(qs.total_logical_reads * 8.0 / 1024                             AS decimal(18,2)) AS total_logical_read_mb,
    CAST(qs.total_logical_reads * 8.0 / 1024 / NULLIF(qs.execution_count, 0) AS decimal(18,2)) AS avg_logical_read_mb,
    CAST(qs.total_elapsed_time  / 1000.0                                AS decimal(18,2)) AS total_elapsed_ms,
    COALESCE(DB_NAME(CONVERT(int, pa.value)), DB_NAME(st.dbid)) AS database_name,
    LEFT(REPLACE(REPLACE(LTRIM(st.text), CHAR(13), ' '), CHAR(10), ' '), 140) AS query_text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
OUTER APPLY (
    SELECT TOP (1) value
    FROM sys.dm_exec_plan_attributes(qs.plan_handle)
    WHERE attribute = 'dbid'
) AS pa
ORDER BY qs.total_worker_time DESC;
execution_counttotal_cpu_msavg_cpu_mstotal_logical_read_mbavg_logical_read_mbtotal_elapsed_msdatabase_namequery_text
11332.861332.8626.0426.041336.79stoxxWAITFOR DELAY '00:00:00.200'; SELECT TOP 500000 ColA, ColB, ColC FROM ( SELECT TOP 500000 symbol AS ColA, CA
11174.731174.733.983.981174.75stoxxSELECT COUNT(*) AS query_count FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp WHERE CAST(qp.query_pla
55542.399.861.580.03617.81masterSELECT target_data FROM sys.dm_xe_session_targets xet WITH(nolock) JOIN sys.dm_xe_sessions xes WITH(nolock) ON xet.event_session_address =
1221.82221.82116.18116.18228.90stoxxSELECT TOP 10 OBJECT_SCHEMA_NAME(ips.object_id) + '.' + OBJECT_NAME(ips.object_id) AS table_name, i.name AS index_name, ips.inde
1174.82174.821382.331382.33178.18stoxxSELECT db_id() as database_id, sm.[is_inlineable] AS InlineableScalarCount, sm.[inline_type] AS InlineType, C

This ranking is real but not workload-representative. Four of the five top rows have execution_count = 1, which means they are one-off administrative or test statements rather than chronic hotspots. The WAITFOR DELAY + SELECT TOP 500000 row is a single synthetic demo burning 1.33 s of CPU. The second row is an audit helper query against dm_exec_query_stats itself — the observer effect in action. The most interesting row is the master context statement with execution_count = 55: that is the SSMS-style XE session targets poll, which runs frequently and is normal. The only genuinely workload-adjacent entry is the scalar-UDF introspection query at row 5, which scans 1382 MB of logical reads in a single pass against engine metadata. The correct interpretation is not “these are the application’s worst queries”; it is “the current cache history is dominated by recent audit and demo activity.” In a genuine production audit window, this phase should surface repeated business queries or recurring ETL statements, not setup DDL or monitoring helpers — and Query Store should be consulted before any tuning decision.

ColumnValueWatchMeaningImplication
execution_count1 with very high totalsDependsOne-off expensive statement.Different tuning priority from a fast statement executed millions of times.
execution_countHigh (thousands+)DependsRepeated statement.Even moderate per-execution cost can become a major workload tax.
execution_countMany rows with 1DependsParameterization failing or plans constantly churning.Check optimize for ad hoc workloads and forced parameterization in Phase 9.
total_cpu_msHighDependsStatement consumed significant cumulative CPU since entering cache.Good shortlist for deeper plan analysis.
avg_cpu_msHigh with low execution_countDependsSingle heavy execution.Capture actual plan with sys.dm_exec_query_plan; may be a reporting query, maintenance, or monster ad hoc.
avg_cpu_msModerate with high execution_countChronic hot statement.Primary tuning target.
total_logical_read_mbHighDependsStatement touched many cached pages cumulatively.Often points to scans, wide lookups, or large intermediate work.
avg_logical_read_mb> 100 with low execution_countDependsWide scan or large materialization per run.Strong candidate for index, predicate, or aggregation tuning.
total_elapsed_ms - total_cpu_msLarge positive deltaSignificant wait time per execution.Query waits on locks, I/O, or grants — join with Phase 3 dominant waits.
database_namemaster or msdbDependsMonitoring or engine-internal query.Usually observer effect; confirm by looking at query_text.

Phase 6 | Index Health

Index health is not just fragmentation. The point of this phase is to determine whether physically meaningful indexes are degraded enough to matter and whether the data-access layer is likely to benefit from maintenance or design changes.

Actionable physical-design signals

Check fragmentation on materially sized indexes

As Phase 6 triage, or before and after an index-maintenance window to verify results. It is typically triggered by complaints about slow range scans, capacity planning for index rebuilds, evaluating whether an existing maintenance job is working. Single T-SQL session, VIEW DATABASE STATE, read-only. LIMITED mode reads only the b-tree parent pages, which is cheap; SAMPLED and DETAILED modes are much more expensive and can be disruptive on very large tables. Identify clustered and nonclustered B-tree indexes whose logical fragmentation is high and whose page count is large enough to matter operationally — fragmentation on a tiny index rarely justifies any action.

Logical fragmentation measures the proportion of pages in the leaf level that are out of order relative to allocation. It is the right signal for range-scan cost on B-trees but is meaningless on columnstore indexes (use sys.dm_db_column_store_row_group_physical_stats for those).

FieldSourceType / UnitMeaning
table_nameOBJECT_SCHEMA_NAME(object_id) + '.' + OBJECT_NAME(object_id)sysnameSchema-qualified base table or indexed view name.
index_namesys.indexes.namesysnameIndex name. NULL for the heap (index_id = 0).
type_descsys.indexes.type_descnvarchar(60)HEAP, CLUSTERED, NONCLUSTERED, XML, SPATIAL, CLUSTERED COLUMNSTORE, NONCLUSTERED COLUMNSTORE.
avg_fragmentation_in_percentsys.dm_db_index_physical_stats.avg_fragmentation_in_percentfloat, %Logical fragmentation percentage — how many leaf pages are out of physical order.
page_countsys.dm_db_index_physical_stats.page_countbigintNumber of index or data pages at the level LIMITED mode walked.

Inputs to sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED'):

ParameterValueMeaning
database_idDB_ID()Run in the current database context. NULL would scan every database and is catastrophic on a large instance.
object_idNULLEvery table/indexed view. Pass a specific object_id to scope.
index_idNULLEvery index on each object. Pass 0 for heaps, 1 for clustered, higher for nonclustered.
partition_numberNULLEvery partition.
mode'LIMITED'Parent-level only; cheapest. 'SAMPLED' reads 1% of leaf pages; 'DETAILED' walks every leaf page.

Find the most fragmented clustered and nonclustered indexes that are large enough to matter operationally.

SELECT TOP (10)
    OBJECT_SCHEMA_NAME(ips.object_id) + '.' + OBJECT_NAME(ips.object_id) AS table_name,
    i.name      AS index_name,
    i.type_desc,
    CAST(ips.avg_fragmentation_in_percent AS decimal(18,2)) AS avg_fragmentation_in_percent,
    ips.page_count
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') AS ips
JOIN sys.indexes AS i
  ON ips.object_id = i.object_id
 AND ips.index_id  = i.index_id
WHERE ips.index_id > 0
  AND i.type_desc IN ('CLUSTERED', 'NONCLUSTERED')
  AND ips.page_count >= 200
  AND OBJECT_NAME(ips.object_id) NOT LIKE 'demo_%'
ORDER BY ips.avg_fragmentation_in_percent DESC;
table_nameindex_nametype_descavg_fragmentation_in_percentpage_count
silver.stoxxusa50_ohlcvIX_silver_stoxxusa50_ohlcv_symbol_dateNONCLUSTERED46.23212
silver.stoxxasia50_ohlcvIX_silver_stoxxasia50_ohlcv_symbol_dateNONCLUSTERED41.81232
silver.eurostoxx50_ohlcvIX_silver_eurostoxx50_ohlcv_symbol_dateNONCLUSTERED40.59239
silver.stoxxusa50_ohlcvPK__stoxxusa__3213E83FC84E3F24CLUSTERED1.50734
silver.stoxxasia50_ohlcvPK__stoxxasi__3213E83F66A8DE5ECLUSTERED0.54738
silver.eurostoxx50_ohlcvPK__eurostox__3213E83FDF67D274CLUSTERED0.52766
silver.oil20_ohlcvPK__oil20_oh__3213E83F544EB286CLUSTERED0.36279

The only nontrivial fragmentation is on the symbol_date nonclustered indexes for three silver OHLCV tables, where logical fragmentation sits between 40% and 46%. Even there, page counts are only 212-239 pages — roughly 1.7-1.9 MB each. At that physical scale the entire index fits comfortably in buffer pool after the first scan, and logical order matters less than it would on a multi-gigabyte index. The clustered primary keys on the same silver tables are healthy at under 2% fragmentation. This is a textbook reminder that every fragmentation percentage has to be read alongside page_count: a 46% headline on a 212-page index is cosmetically ugly but operationally cheap to ignore, while 46% on a 5-million-page index would be a production emergency. Maintenance on these indexes can be safely bundled into the next normal reorganize window rather than scheduled urgently.

ColumnValueWatchMeaningImplication
avg_fragmentation_in_percent< 10Low logical fragmentation.Usually leave it alone. Microsoft’s long-standing guidance.
avg_fragmentation_in_percent10-30DependsModerate fragmentation.REORGANIZE is usually sufficient; online and low-impact.
avg_fragmentation_in_percent> 30DependsHigh logical fragmentation.REBUILD is usually preferred, but only if page_count and workload justify the cost.
page_count< 200Tiny index.Fragmentation is irrelevant; the index fits in buffer pool in one or two I/Os.
page_count200-1000DependsSmall to modest index.High fragmentation has limited practical impact; bundle with routine maintenance.
page_count1000-100000DependsMid-sized index.Fragmentation starts to matter for range scans; usual target for weekly maintenance.
page_count> 100000❌ when fragmentedLarge index.Fragmentation and density both matter; plan maintenance carefully, consider online rebuild.
type_descNONCLUSTEREDDependsSecondary access path.Often the first place where fragmentation becomes visible in range-scan queries.
type_descCLUSTEREDDependsTable’s primary B-tree structure.High fragmentation here affects the base row order and every nonclustered lookup.
type_descCLUSTERED COLUMNSTORE❌ wrong toolColumnstore index.avg_fragmentation_in_percent is meaningless here — use sys.dm_db_column_store_row_group_physical_stats and watch deleted_rows / state_desc.

Phase 7 | TempDB Health

tempdb pressure shows up as space growth, version-store accumulation, spills, or allocation contention. This phase verifies current space consumption and whether the file layout follows the equal-size, fixed-growth pattern that avoids needless churn.

Space and layout

Measure current tempdb space usage

When tempdb is suspected as a bottleneck, or as routine Phase 7 triage. It is typically triggered by complaints of “everything is slow”, tempdb autogrowth events, RCSI/SI workloads showing unusual version-store growth, or spill warnings in query plans. Single T-SQL session, VIEW SERVER STATE, read-only, executed in tempdb context. The USE tempdb is required because sys.dm_db_file_space_usage is a database-scoped DMV and the tempdb row is the only operationally meaningful one. Attribute current tempdb footprint to the four categories that matter: user temp objects, internal worktables and spill structures, row-versioning store, and remaining free space.

sys.dm_db_file_space_usage exposes page-level allocation counts for the database’s files. For any database other than tempdb, the DMV still works but the interesting columns are the tempdb-specific ones (user_object_*, internal_object_*, version_store_*, unallocated_extent_*).

FieldSourceType / UnitMeaning
user_objects_mbSUM(user_object_reserved_page_count) * 8 / 1024.0decimal, MBPages reserved for user-created temp objects: #temp tables, ##global_temp, table variables (materialized), local temporary table types.
internal_objects_mbSUM(internal_object_reserved_page_count) * 8 / 1024.0decimal, MBPages reserved for engine worktables: hash spills, sort spills, cursor worktables, XML/LOB intermediate storage, columnstore build temp.
version_store_mbSUM(version_store_reserved_page_count) * 8 / 1024.0decimal, MBPages reserved for the row-version store used by RCSI, SI, online rebuild, MARS, and trigger after-image buffers.
free_space_mbSUM(unallocated_extent_page_count) * 8 / 1024.0decimal, MBUnused extents across every tempdb data file.

Measure the current tempdb footprint of user objects, internal objects, version store, and free space.

USE tempdb;
SELECT
    SUM(user_object_reserved_page_count)     * 8 / 1024.0 AS user_objects_mb,
    SUM(internal_object_reserved_page_count) * 8 / 1024.0 AS internal_objects_mb,
    SUM(version_store_reserved_page_count)   * 8 / 1024.0 AS version_store_mb,
    SUM(unallocated_extent_page_count)       * 8 / 1024.0 AS free_space_mb
FROM sys.dm_db_file_space_usage;
user_objects_mbinternal_objects_mbversion_store_mbfree_space_mb
1.9375000.6875000.00000059.375000

tempdb is effectively idle right now. User objects consume just under 2 MB, internal objects under 1 MB, version store is exactly 0 MB, and only 59 MB of free space remains — a sharp contrast with earlier audits that showed multi-GB tempdb files. The reason is visible in the next query: tempdb has been reset to eight 8 MB data files and one 8 MB log file, not the previous 328 MB-per-file configuration. That small footprint is not automatically wrong, but it is fragile: any single query that allocates more than ~60 MB of worktable or user-temp space will trigger autogrowth, and the 64 MB fixed growth step means each growth event roughly doubles the data-file size. The correct reading is “no current pressure, very small headroom”, and the operational recommendation is to pre-size tempdb data files to match expected peak workload rather than relying on incremental autogrowth.

ColumnValueWatchMeaningImplication
user_objects_mbLowTemporary user objects are small right now.No visible user-temp pressure.
user_objects_mbGrowing persistentlyLong-lived #temp tables or leaked table variables.Review sessions that create but never drop temp structures.
internal_objects_mbLowSort/hash spill structures are minimal right now.No immediate spill-driven pressure signal.
internal_objects_mbLargeSignificant operator spills.Correlate with memory grant warnings, cardinality estimates, and DOP choices.
version_store_mb0No meaningful row-version accumulation.RCSI/SI or online operations are not consuming version space right now.
version_store_mbGrowing persistentlyVersion store is accumulating.Investigate long snapshot readers, RCSI, and cleanup lag in sys.dm_tran_version_store_space_usage.
free_space_mb>= 30% of total file sizeFiles have headroom.Current operations should not trigger immediate autogrowth.
free_space_mb< 10% of total file sizeNear-term autogrowth likely.Pre-grow tempdb to a stable size during a maintenance window.

Review tempdb file layout and growth behavior

After checking current space usage, as the second Phase 7 query. It is typically triggered by first audit of an instance, post-install validation, investigation of PFS/GAM/SGAM latch contention, tuning to eliminate autogrowth skew. Single T-SQL session, VIEW ANY DEFINITION against tempdb.sys.database_files, read-only. The query is trivial. Verify the file count, equal sizing, and growth settings match the modern tempdb guidance — 1 file per logical CPU up to 8, equal sizes, fixed growth, tempdb metadata optimization when available.

From SQL Server 2016 onward, the installer pre-sizes multiple equally-sized tempdb data files; from SQL Server 2019 and later, ALTER SERVER CONFIGURATION SET MEMORY_OPTIMIZED TEMPDB_METADATA = ON further reduces PFS latch contention. The file layout below still matters because wrong sizes or percentage growth can silently re-introduce allocation skew.

FieldSourceType / UnitMeaning
namesys.database_files.namesysnameLogical file name (e.g. tempdev, tempdev2, templog).
size_mbsize * 8 / 1024integer, MBCurrent file size (pages × 8 KB ÷ 1024).
growth_mbgrowth * 8 / 1024 when is_percent_growth = 0integer, MBFixed growth increment. Meaningless for percent-growth files.
is_percent_growthsys.database_files.is_percent_growthbit0 = fixed MB growth, 1 = percentage growth.

Review tempdb file count, file-size symmetry, and growth settings.

SELECT
    name,
    size   * 8 / 1024 AS size_mb,
    growth * 8 / 1024 AS growth_mb,
    is_percent_growth
FROM tempdb.sys.database_files
ORDER BY file_id;
namesize_mbgrowth_mbis_percent_growth
tempdev8640
templog8640
tempdev28640
tempdev38640
tempdev48640
tempdev58640
tempdev68640
tempdev78640
tempdev88640

The file layout is structurally correct but undersized. There are eight data files plus one log file, every file is exactly 8 MB, and every file uses a fixed 64 MB growth increment with is_percent_growth = 0. The file count matches Microsoft’s guidance of one tempdb data file per logical CPU up to a starting point of eight — on this 16-logical-CPU host, eight is the accepted baseline. Equal sizes are correct for PFS/GAM allocation balancing. The concern is absolute scale: 8 MB is the SQL Server install default, not a production-sized value. At this size, the first query that spills or builds a large worktable will immediately trigger autogrowth on multiple files, each expanding by 64 MB. Autogrowth events also take a brief file-level latch during zero-fill (unless instant file initialization is enabled), which can stall tempdb allocation path under concurrency. The correct hardening action is to pre-grow every data file to match expected peak workload — commonly 1 GB to 4 GB per file on a mid-sized production instance — and leave growth enabled only as a safety net.

ColumnValueWatchMeaningImplication
File count (data)logical_cpus / 2 to logical_cpus, capped at 8Enough files to spread PFS/GAM allocation.Matches Microsoft’s modern guidance.
File count (data)1Single tempdb data file.Any concurrent worktable allocation will contend on PFS latches.
Data file sizesEqualFiles can participate evenly in round-robin allocation.Good baseline for reducing allocation skew.
Data file sizesUnequalOne or more files may receive disproportionate activity (SQL Server biases toward larger files).Rebalance before diagnosing deeper tempdb contention.
Data file sizesSmall defaults (8 MB)Freshly installed tempdb untouched.Pre-grow files to the workload’s peak expected size before production use.
is_percent_growth0Growth uses a fixed increment.Predictable expansion behavior.
is_percent_growth1Growth is percentage-based.Later growth events become increasingly large and unpredictable — growing tempdev from 8 GB by 10% allocates 800 MB in one stall.
growth_mb64-512 MB fixedGrowth step is operationally controlled.Better than tiny frequent autogrowth events.
growth_mb1-10 MB fixedAutogrowth will fire extremely often under load.Raise to at least 64 MB per file.
Log file count1tempdb must have exactly one log file.Multiple log files provide no benefit and confuse maintenance.

Phase 8 | Blocking and Deadlocks

Concurrency issues can look like CPU problems, I/O problems, or generic slowness. This phase checks for a live blocking chain first, then uses the deadlock counter only as a coarse triage signal.


stateDiagram-v2
    [*] --> Idle
    Idle --> LiveBlocking: user report or dashboard alert
    LiveBlocking --> HeadBlockerIdentified: sys.dm_exec_requests + blocking_session_id chain walk
    HeadBlockerIdentified --> Investigating: inspect running_statement + open_tran + wait_type
    Investigating --> Resolved: safe to kill or head-blocker commits
    Investigating --> Escalated: business-critical batch or DDL
    Escalated --> Resolved: stakeholder approval and controlled kill
    Resolved --> Idle
    Idle --> DeadlockSignal: Number of Deadlocks/sec > 0
    DeadlockSignal --> XEventsCollection: capture system_health deadlock graph
    XEventsCollection --> RootCause: parse victim, resources, SPID graph
    RootCause --> Idle

Current concurrency state

Check for active user blocking right now

Every time a user reports “the database is slow”, at the start of Phase 8, and whenever Phase 3 waits show LCK_M_* dominance. It is typically triggered by live incident, monitoring alert on blocked sessions, investigation of long-running transactions, post-deploy verification. Single T-SQL session, VIEW SERVER STATE, read-only. Point-in-time snapshot — run twice a few seconds apart to distinguish transient blocking from a stable chain. Capture every active user request with the running statement, the exact wait type, the blocking session (if any), and CPU/I/O totals so a head blocker can be found without ambiguity.

A single snapshot captures the currently executing statement only (via statement-offset substring), not the whole batch. Run the query again 5-10 s later; if the same session_id is still listed with the same blocking_session_id, the chain is stable and the blocking is real, not transient.

FieldSourceType / UnitMeaning
session_idsys.dm_exec_requests.session_idsmallintSession executing this request.
database_nameDB_NAME(sys.dm_exec_requests.database_id)sysnameDatabase context the request is running under.
login_namesys.dm_exec_sessions.login_namenvarchar(128)Authenticated principal.
host_namesys.dm_exec_sessions.host_namenvarchar(128)Client machine name.
program_namesys.dm_exec_sessions.program_namenvarchar(128)Client application identifier string.
statussys.dm_exec_requests.statusnvarchar(30)running, runnable, suspended, sleeping, background.
commandsys.dm_exec_requests.commandnvarchar(32)Engine command family (e.g. SELECT, INSERT, BACKUP DATABASE).
wait_typesys.dm_exec_requests.wait_typenvarchar(60)Current wait type while the request is suspended. NULL when not waiting.
wait_time_mssys.dm_exec_requests.wait_timeint, msCurrent wait duration for the active wait type.
cpu_time_mssys.dm_exec_requests.cpu_timeint, msCPU consumed by this request so far.
elapsed_time_mssys.dm_exec_requests.total_elapsed_timeint, msWall-clock time since the request started.
logical_readssys.dm_exec_requests.logical_readsbigintPages read from buffer pool.
readssys.dm_exec_requests.readsbigintPhysical reads (disk).
writessys.dm_exec_requests.writesbigintPages written.
blocking_session_idsys.dm_exec_requests.blocking_session_idsmallintSession that currently holds a lock this request is waiting on. 0 = not blocked.
running_statementSubstring of sys.dm_exec_sql_text.text using statement_start_offset and statement_end_offsetnvarcharOnly the statement currently executing inside the batch, not the whole batch.

Inspect currently active user requests, including waits, blocking session IDs, and the exact running statement.

SELECT
    r.session_id,
    DB_NAME(r.database_id) AS database_name,
    s.login_name,
    s.host_name,
    s.program_name,
    r.status,
    r.command,
    r.wait_type,
    r.wait_time AS wait_time_ms,
    r.cpu_time AS cpu_time_ms,
    r.total_elapsed_time AS elapsed_time_ms,
    r.logical_reads,
    r.reads,
    r.writes,
    r.blocking_session_id,
    LEFT(REPLACE(REPLACE(LTRIM(SUBSTRING(
        st.text,
        (r.statement_start_offset / 2) + 1,
        CASE
            WHEN r.statement_end_offset = -1 THEN (DATALENGTH(st.text) - r.statement_start_offset) / 2 + 1
            ELSE (r.statement_end_offset - r.statement_start_offset) / 2 + 1
        END
    )), CHAR(13), ' '), CHAR(10), ' '), 160) AS running_statement
FROM sys.dm_exec_requests AS r
JOIN sys.dm_exec_sessions AS s
  ON r.session_id = s.session_id
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS st
WHERE r.session_id <> @@SPID
  AND s.is_user_process = 1
ORDER BY r.total_elapsed_time DESC, r.session_id;
session_iddatabase_namelogin_namehost_nameprogram_namestatuscommandwait_typewait_time_mscpu_time_mselapsed_time_mslogical_readsreadswritesblocking_session_idrunning_statement

There was no active user blocking at capture time. That is the correct result to see in a calm system. It does not prove blocking never happens, but it does mean the current point-in-time performance state is not explained by a live blocking chain. For production, re-run this query whenever a user reports slowness and compare the two captures: transient rows that appear and disappear are normal concurrency, while a persistent row with a non-zero blocking_session_id across multiple captures is a head blocker that must be investigated before any tuning recommendation.

ColumnValueWatchMeaningImplication
Result setNo rowsNo other user request is active right now.No live blocking or long-running user request is visible.
Result setTransient rows that change between capturesNormal concurrency.Queries are executing and finishing; no head blocker.
Result setPersistent row across multiple capturesLong-running request or stuck session.Inspect running_statement, open transactions, and wait type.
statusrunningDependsActively executing on a scheduler.Just in flight; not blocked.
statusrunnableDependsWaiting for a CPU scheduler.Often CPU pressure; correlate with Phase 3 SOS_SCHEDULER_YIELD.
statussuspended with blocking_session_id > 0Request is waiting on another session.Find and resolve the head blocker first.
statussuspended with blocking_session_id = 0DependsWaiting on resource other than a lock (I/O, memory grant, network).Interpret via wait_type.
statussleepingDependsNo active request — session is idle inside an open transaction.Investigate via sys.dm_tran_active_transactions to see if the transaction is still open.
wait_typeLCK_M_*❌ when paired with wait timeLock wait is active right now.Blocking is a live problem, not just a historical one.
wait_typePAGEIOLATCH_SH / EX❌ when paired with wait timeWaiting on physical I/O.Storage path or cache miss — see Phase 4.
wait_typeRESOURCE_SEMAPHOREWaiting for workspace memory grant.See Phase 2.
blocking_session_id0Not blocked.Request is moving.
blocking_session_idNon-zeroHead blocker exists.Walk the chain — the head blocker has blocking_session_id = 0 (or does not appear in dm_exec_requests if it is sleeping).
running_statementLong DDL or open transaction batchDependsPotential head-blocker workload.Validate change windows and transaction scope.
elapsed_time_ms - cpu_time_msVery large deltaMost of the wall-clock time is waits, not CPU.Interpret via wait_type.

Check the deadlock counter carefully

As a quick secondary check after the live-requests query. It is typically triggered by user reports of “it just failed randomly and retried”, blocking analysis already finished, validating whether deadlock activity exists at all. Single T-SQL session, VIEW SERVER STATE, read-only. Trivial query cost. Determine whether the instance has produced any deadlocks since startup. This is a binary triage signal, not a root-cause diagnostic.

SQL Server counters named /sec are actually per-second ratios, but Number of Deadlocks/sec is implemented as a total count — it increments once per deadlock and never decrements. Treating cntr_value as a cumulative counter is the correct reading regardless of the column name.

FieldSourceType / UnitMeaning
object_namesys.dm_os_performance_counters.object_namenchar(128)Performance object — SQLServer:Locks for deadlocks.
counter_namesys.dm_os_performance_counters.counter_namenchar(128)Counter name — filtered to Number of Deadlocks/sec.
instance_namesys.dm_os_performance_counters.instance_namenchar(128)_Total for the aggregate across all lock resource types, or a specific resource type (Page, Key, etc.).
cntr_valuesys.dm_os_performance_counters.cntr_valuebigintCumulative count of deadlocks observed since service start, despite the /sec suffix in the counter name.

Counter vs deadlock graph

Number of Deadlocks/sec is a performance counter, not a deadlock graph. A single snapshot can tell you that deadlock activity exists, but it does not tell you which objects, statements, or principals were involved, whether the same pattern is repeating, or which session was chosen as victim.

Escalate with Extended Events

If this counter is non-zero or trending upward, collect deadlock graphs from the default system_health Extended Events session (which captures xml_deadlock_report by default on every SQL Server install) or from a dedicated Extended Events session before recommending any code or index change. Query the ring buffer with sys.fn_xe_file_target_read_file against the system_health*.xel files.

Check whether SQL Server is currently exposing a non-zero deadlock counter for the instance.

SELECT
    object_name,
    counter_name,
    instance_name,
    cntr_value
FROM sys.dm_os_performance_counters
WHERE counter_name  = 'Number of Deadlocks/sec'
  AND instance_name = '_Total';
object_namecounter_nameinstance_namecntr_value
SQLServer:LocksNumber of Deadlocks/sec_Total1

The counter shows 1 deadlock since the 5-hour-old startup. That is all this query can prove safely: deadlock activity exists, recurrence is unknown, business impact is unknown, and the graph itself is not in this result. The correct next step is to query system_health Extended Events with sys.fn_xe_file_target_read_file, filter for xml_deadlock_report, and parse the victim SPID, the involved objects, and the lock mode sequence — only then is it possible to recommend a fix such as changing access order, adding a covering index, or forcing a smaller lock footprint. Do not speculate about cause from this counter alone.

ColumnValueWatchMeaningImplication
cntr_value0No deadlock counter evidence in the current snapshot.No immediate deadlock follow-up from this counter alone.
cntr_value> 0 small and stableDependsDeadlock activity has been observed but is not accelerating.Collect real deadlock graphs before recommending a fix.
cntr_valueRising across consecutive capturesRecurring deadlock pattern.High-priority investigation via system_health Extended Events.
instance_name_TotalDependsAggregate instance counter.Good for quick triage, not root-cause analysis.
instance_nameKeyDependsDeadlock was on a key-range or row-level key lock.Typically involves nonclustered index keys and range scans.
instance_namePageDependsDeadlock was on a page-level lock.Lock escalation or hot-page contention.
instance_nameObjectDependsDeadlock was on a schema or table-level lock.Usually DDL vs DML — check for online rebuilds during DML windows.

Phase 9 | Statistics and Plan Cache

The optimizer depends on current statistics and a healthy plan cache. This phase checks for stale statistics on real user tables, then measures whether ad hoc plan caching is consuming disproportionate memory.


flowchart TD
    S["Plan cache composition"] --> A{"Adhoc share<br/>> 30% of cache_mb?"}
    A --> Y1([YES])
    A --> N1([NO])
    N1 --> K["Leave configuration as is;<br/>monitor long-term"]
    Y1 --> B{"Single-use adhoc<br/>> 50% of Adhoc cache?"}
    B --> Y2([YES])
    B --> N2([NO])
    N2 --> L["Plans are reused;<br/>leave optimize_for_ad_hoc OFF"]
    Y2 --> C{"optimize for<br/>ad hoc workloads = 1?"}
    C --> Y3([YES])
    C --> N3([NO])
    Y3 --> M["Stubs already active;<br/>focus on parameterization<br/>and forced parameterization"]
    N3 --> D["Enable optimize for<br/>ad hoc workloads<br/>and review literals<br/>in application code"]

    classDef yesNode fill:#1f3b2d,stroke:#73d13d,stroke-width:2px,color:#c0caf5,font-weight:bold;
    classDef noNode fill:#4a1f24,stroke:#db4b4b,stroke-width:2px,color:#c0caf5,font-weight:bold;
    class Y1,Y2,Y3 yesNode;
    class N1,N2,N3 noNode;

Optimization inputs and cache hygiene

Find user tables with the stalest statistics

When the optimizer is suspected of choosing bad plans, or as routine Phase 9 triage. It is typically triggered by complaints of intermittent slow queries, cardinality mis-estimates in actual plans, after a bulk load or large DELETE, before and after stats maintenance. Single T-SQL session, VIEW DATABASE STATE, read-only per database. The query runs in whatever database is current, so set context explicitly with USE <db> in multi-database audits. Find statistics objects on real user tables whose modification counter has drifted significantly since the last update, so that the stats refresh candidate list is data-driven rather than calendar-driven.

SQL Server’s auto-update threshold under the default trace flag 2371 (active by default from compatibility level 130+) uses a dynamic formula roughly equal to SQRT(1000 * rows), so large tables trigger updates earlier than the old flat 20% + 500 rule. On a 506-row table the legacy rule still applies and a modification counter of 14674 is extreme — the auto-update will fire on the next qualifying query, but that does not help plans cached before that fires.

FieldSourceType / UnitMeaning
table_nameOBJECT_SCHEMA_NAME(s.object_id) + '.' + OBJECT_NAME(s.object_id)sysnameSchema-qualified table name that owns the statistics object.
stat_namesys.stats.namesysnameStatistics object name. Auto-created single-column stats follow the _WA_Sys_* pattern.
last_updatedsys.dm_db_stats_properties.last_updateddatetime2(7)Timestamp of the last histogram refresh. NULL if the stat was never updated.
rowssys.dm_db_stats_properties.rowsbigintRow count used as the basis for the current histogram.
modification_countersys.dm_db_stats_properties.modification_counterbigintNumber of leading-column modifications accumulated since last_updated.
pct_modified100.0 * modification_counter / NULLIF(rows, 0)decimal, %Modification ratio. Extreme on tiny tables where a small denominator magnifies the number.

Rank user-table statistics objects by how many leading-column modifications have accumulated since the last update.

USE stoxx;
SELECT TOP (10)
    OBJECT_SCHEMA_NAME(s.object_id) + '.' + OBJECT_NAME(s.object_id) AS table_name,
    s.name AS stat_name,
    sp.last_updated,
    sp.rows,
    sp.modification_counter,
    CAST(100.0 * sp.modification_counter / NULLIF(sp.rows, 0) AS decimal(10,2)) AS pct_modified
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE OBJECTPROPERTY(s.object_id, 'IsUserTable') = 1
  AND sp.modification_counter > 0
  AND OBJECT_NAME(s.object_id) NOT LIKE 'demo_%'
ORDER BY sp.modification_counter DESC;
table_namestat_namelast_updatedrowsmodification_counterpct_modified
dbo.gold_daily_summaryIX_gold_daily_date2026-03-29 20:36:12.4333333506146742900.00
dbo.gold_daily_summary_WA_Sys_00000003_69FBBC1F2026-03-29 21:33:37.240000050686021700.00

This is an actionable finding. dbo.gold_daily_summary has two statistics objects whose modification counters (14674 and 8602) are both far larger than the current row count (506). The pct_modified values look extreme (2900% and 1700%) specifically because the denominator is tiny; on a 506-row table, even moderate churn generates eye-catching ratios. What matters is the absolute relationship — when modifications exceed rows by an order of magnitude, the histogram almost certainly no longer describes the current data distribution, and the optimizer will produce cardinality estimates based on yesterday’s shape. The fix is the same regardless of table size: a targeted UPDATE STATISTICS dbo.gold_daily_summary WITH FULLSCAN, or a more aggressive global job that uses sys.dm_db_stats_properties to pick candidates. Refresh these before spending effort tuning plans that consume these statistics — many plan problems on this table will disappear after the refresh.

ColumnValueWatchMeaningImplication
last_updatedRecent (hours, days)Statistics are current.Plans built from them should reflect the live distribution.
last_updatedOld relative to write activityStatistics have not been refreshed recently.Candidate for manual update or improved stats maintenance.
last_updatedNULLDependsStatistics object has never been updated (just created).Auto-update has not fired yet; the first qualifying query will trigger a synchronous refresh.
modification_counter0No changes since last refresh.Fresh.
modification_counter< 20% of rowsDependsModerate churn.Usually benign on mid-sized tables.
modification_counter>= rowsChange volume equals or exceeds the base cardinality.Refresh stats explicitly — estimates likely drift badly.
pct_modified< 10Small relative churn.No action needed.
pct_modified10-100DependsSignificant relative churn.Update on next maintenance window.
pct_modified> 100More modifications than rows in the histogram base.Refresh immediately; flag table for async stats updates (AUTO_UPDATE_STATISTICS_ASYNC).
rowsSmallDependsSmall denominator.Percentage can look extreme quickly, but still indicates stale stats when modifications are large.
rowsLargeDependsLarge denominator.Modifications need to be commensurate to trigger a refresh under trace flag 2371.

Review plan-cache composition by plan type

After the stale stats check, as the second Phase 9 query. It is typically triggered by suspected compilation pressure, memory cap is being hit disproportionately by plan cache, or Phase 1 shows optimize for ad hoc workloads = 0 on an ad hoc-heavy workload. Single T-SQL session, VIEW SERVER STATE, read-only. Grouping the entire plan cache is cheap. Classify cached plans by object type and measure how much cache memory and reuse each class owns, so that parameterization and cache-hygiene decisions are based on real distribution rather than assumptions.

sys.dm_exec_cached_plans exposes one row per cached compiled plan. The objtype column distinguishes the plan class.

FieldSourceType / UnitMeaning
plan_typesys.dm_exec_cached_plans.objtypenvarchar(9)Plan class. Finite domain: Adhoc, Prepared, Proc, View, Trigger, Default, Rule, UsrTab (user-defined table type), Check.
plan_countCOUNT(*) per objtypeintNumber of distinct cached plan entries.
cache_mbSUM(size_in_bytes) / 1048576.0decimal, MBTotal cache footprint of this plan class.
total_use_countSUM(usecounts)bigintTotal times plans of this class have been reused since entering cache.
avg_use_countAVG(CONVERT(float, usecounts))decimal, countMean reuse per cached plan. Low values on ad hoc indicate parameterization failure.

Measure how plan-cache memory is distributed across ad hoc, prepared, view, and stored-procedure plans.

WITH plans AS (
    SELECT
        objtype               AS plan_type,
        COUNT(*)              AS plan_count,
        SUM(size_in_bytes)    / 1048576.0       AS cache_mb,
        SUM(usecounts)        AS total_use_count,
        AVG(CONVERT(float, usecounts))           AS avg_use_count
    FROM sys.dm_exec_cached_plans
    GROUP BY objtype
)
SELECT
    plan_type,
    plan_count,
    CAST(cache_mb      AS decimal(18,2)) AS cache_mb,
    total_use_count,
    CAST(avg_use_count AS decimal(18,2)) AS avg_use_count
FROM plans
ORDER BY cache_mb DESC;
plan_typeplan_countcache_mbtotal_use_countavg_use_count
Adhoc59381.2728044.73
View38360.6529527.71
Proc6820.766048.88
Prepared698.883424.96
UsrTab50.17357.00
Rule60.1710116.83
Trigger20.1731.50
Default30.03155.00

The plan cache is dominated by Adhoc at 81.27 MB across 593 plans, followed by View at 60.65 MB. Stored procedures are a distant third at 20.76 MB across only 68 plans — the procs that do exist are being reused effectively (avg_use_count = 8.88), but there are not many of them. The Adhoc category’s avg_use_count is 4.73, which sounds acceptable on its face, but the next query will show that the headline number is hiding a long tail: when many ad hoc plans have usecounts = 1 mixed with a handful that have very high counts, the mean is meaningless. Phase 1 already showed optimize for ad hoc workloads = 0, so every ad hoc plan is currently stored as a full compiled plan rather than a stub on first execution. The consequence is that the cache is paying full plan cost even for plans that will never be reused, which is the reason the next query zooms in on single-use ad hoc specifically. The View footprint at 60 MB is a normal side-effect of indirect compilation — each statement that references a view compiles a plan scoped to the referencing query, so plan_count inflates naturally in view-heavy applications.

ColumnValueWatchMeaningImplication
plan_typeAdhoc dominates cache_mbDependsAd hoc statements occupy most cache memory.Often a sign to review parameterization and ad hoc plan reuse.
plan_typeProc dominatesDependsStored procedures own most cache memory.Often expected in proc-heavy systems.
plan_typePrepared dominatesMost plans are parameterized via sp_executesql or client-side prepare.Healthy pattern for applications using parameterized ORMs.
plan_typeView largeDependsView-heavy application.Check whether the same views are referenced from many distinct calling statements, which inflates plan count naturally.
cache_mbHigh on low-reuse plan typesMemory is tied up in plans with limited reuse value.Consider optimize for ad hoc workloads, parameterization, or cache hygiene investigation.
avg_use_countLow on Adhoc❌ if cache_mb is highPlans are not being reused much.Plan cache may be acting more like a compilation staging area than a reuse asset.
avg_use_countVery high (> 50)Strong reuse.Parameterization is working; leave alone.
plan_countVery high on AdhocEvery query compiles its own plan.Enable optimize for ad hoc workloads and consider forced parameterization.

Quantify single-use ad hoc plan waste

Directly after the plan-cache composition query. It is typically triggered by Adhoc dominates cache_mb, optimize for ad hoc workloads is off, or compile-per-second counters are elevated. Single T-SQL session, VIEW SERVER STATE, read-only, cheap. Measure exactly how much plan-cache memory is held by ad hoc plans with usecounts = 1 — the “compiled once, never reused” pattern that most benefits from stub caching and parameterization.

FieldSourceType / UnitMeaning
plan_typesys.dm_exec_cached_plans.objtype filtered to Adhocnvarchar(9)Plan class — here pinned to Adhoc.
single_use_plan_countCOUNT(*) where usecounts = 1intNumber of ad hoc plans that entered cache and never executed a second time.
single_use_cache_mbSUM(CAST(size_in_bytes AS bigint)) / 1048576.0decimal, MBTotal memory those never-reused plans occupy. The explicit CAST(... AS bigint) prevents overflow when very large instances sum hundreds of MB worth of plan entries.

Measure how much plan-cache memory is currently occupied by single-use ad hoc plans.

SELECT
    objtype                                           AS plan_type,
    COUNT(*)                                          AS single_use_plan_count,
    CAST(SUM(CAST(size_in_bytes AS bigint)) / 1048576.0 AS decimal(18,2)) AS single_use_cache_mb
FROM sys.dm_exec_cached_plans
WHERE usecounts = 1
  AND objtype   = 'Adhoc'
GROUP BY objtype;
plan_typesingle_use_plan_countsingle_use_cache_mb
Adhoc56478.60

This is a clear plan-cache hygiene finding. 564 single-use ad hoc plans are consuming 78.60 MB — essentially the entire 81.27 MB ad hoc cache footprint from the previous query. That means 95% of the ad hoc cache memory is held by plans that compiled, ran once, and have been sitting in cache ever since waiting for a second execution that will never come. This pattern is the canonical reason to enable optimize for ad hoc workloads: when set to 1, first-time ad hoc plans are stored as ~300-byte compiled-plan stubs rather than as full plans, and only get promoted to full plans if a second execution arrives. The saving at this scale is modest (~78 MB), but on production instances with gigabytes of ad hoc plan cache the same ratio translates to multi-GB memory savings with zero downside for plans that legitimately reuse. The deeper fix is still parameterization in the application layer — sp_executesql at the client, or forced parameterization at the database level — so that similar queries actually share plans instead of each generating a new one.

ColumnValueWatchMeaningImplication
single_use_plan_countHigh❌ if paired with memory useMany ad hoc plans were compiled once and never reused.Wasted cache space and extra compilation overhead.
single_use_plan_count> 80% of total Adhoc countParameterization is almost entirely absent.Enable optimize for ad hoc workloads and review client-side query generation.
single_use_cache_mbHighMeaningful memory is tied up in one-time plans.Review ad hoc workload shape and cache strategy.
single_use_cache_mb> 512 MBSignificant absolute waste.Priority hygiene action even on hosts with plenty of RAM.
plan_typeAdhocDependsLiteral or dynamically generated statements.Often benefits from better parameterization patterns.

Phase 10 | Database Files and Log Reuse

The point of this phase is to catch file-growth settings and log reuse blockers before they become outages. File growth should be predictable. Log reuse reasons should make operational sense for the recovery model and workload.

Capacity and recovery signals

Review file sizes and growth increments

At the start of Phase 10, after the workload-level investigation is complete. It is typically triggered by capacity review, post-migration validation, pre-deploy file hardening, or preparation for a database move. Single T-SQL session, VIEW ANY DEFINITION, read-only against sys.master_files. tempdb is excluded here because Phase 7 covers it more precisely. Verify every database file has a predictable size, an explicit or unlimited maximum, and a fixed MB growth increment rather than a percentage. Percentage growth is the root cause of many “my database suddenly allocated 20 GB in one autogrow” tickets.

FieldSourceType / UnitMeaning
database_nameDB_NAME(sys.master_files.database_id)sysnameDatabase the file belongs to.
logical_namesys.master_files.namesysnameLogical file name used in ALTER DATABASE ... MODIFY FILE.
type_descsys.master_files.type_descnvarchar(60)ROWS, LOG, FILESTREAM, or FULLTEXT.
size_mbsize * 8.0 / 1024decimal, MBCurrent file size in MB (pages × 8 KB ÷ 1024).
max_size_mbmax_size converted, or literal 'UNLIMITED' when max_size = -1varchar(50)Maximum file size. max_size = -1 encodes “unlimited”, which is the common default.
growth_increment'<n>%' if is_percent_growth = 1; otherwise '<mb> MB'varcharAutogrowth step rendered as either a percentage string or a fixed MB string.
is_percent_growthsys.master_files.is_percent_growthbit0 = fixed MB growth, 1 = percentage growth.

Review current file sizes, maximum sizes, and autogrowth style for non-tempdb databases.

SELECT TOP (10)
    DB_NAME(database_id) AS database_name,
    name                 AS logical_name,
    type_desc,
    CAST(size * 8.0 / 1024 AS decimal(18,2)) AS size_mb,
    CASE
        WHEN max_size = -1 THEN 'UNLIMITED'
        ELSE CONVERT(varchar(50), CAST(max_size * 8.0 / 1024 AS decimal(18,2)))
    END AS max_size_mb,
    CASE
        WHEN is_percent_growth = 1
            THEN CONCAT(growth, '%')
        ELSE CONCAT(CAST(growth * 8.0 / 1024 AS decimal(18,2)), ' MB')
    END AS growth_increment,
    is_percent_growth
FROM sys.master_files
WHERE database_id <> 2
ORDER BY size DESC;
database_namelogical_nametype_descsize_mbmax_size_mbgrowth_incrementis_percent_growth
stoxxstoxx_logLOG1032.002097152.0064.00 MB0
stoxx_backupstoxx_logLOG1032.002097152.0064.00 MB0
stoxxstoxxROWS712.00UNLIMITED64.00 MB0
stoxx_backupstoxxROWS712.00UNLIMITED64.00 MB0
stoxx_dbstoxx_db_Current_01ROWS256.004096.00128.00 MB0
stoxx_dbstoxx_db_LogLOG256.002048.00128.00 MB0
stoxx_dbstoxx_db_Current_02ROWS256.004096.00128.00 MB0
stoxx_dbstoxx_db_PrimaryROWS128.001024.0064.00 MB0
stoxx_dbstoxx_db_Archive_01ROWS128.002048.0064.00 MB0
msdbMSDBDataROWS15.31UNLIMITED10%1

User databases are configured sensibly: stoxx, stoxx_backup, and stoxx_db data and log files all use fixed MB growth increments (64 MB or 128 MB depending on the file). stoxx_db also shows explicit maximum file sizes (1024-4096 MB) which is a stronger pattern than the UNLIMITED max used on stoxx and stoxx_backup — explicit caps prevent a runaway query from filling the filesystem and turning a performance problem into an outage. The weaker configuration shows up on system databases: MSDBData still uses 10% percentage growth, which is the SQL Server default that nobody has touched since install. On a small 15 MB file this is harmless, but the pattern is worth normalizing in hardened environments because it silently scales up as the file grows — 10% of 2 GB is a 200 MB autogrow event that stalls writers while the engine zero-fills the new pages (unless instant file initialization is enabled for data files; log files are always zero-initialized regardless of IFI).

ColumnValueWatchMeaningImplication
growth_incrementFixed MBGrowth occurs in predictable chunks.Easier capacity planning and less volatile growth behavior.
growth_incrementPercentage❌ in most production casesGrowth size increases as the file grows.Harder to predict and can create very large future autogrowth events.
growth_increment0% / 0 MBAutogrowth disabled.Only appropriate for fixed-size files that are pre-sized for the entire lifetime.
max_size_mbUNLIMITEDDependsNo explicit ceiling is enforced.Fine only if monitoring and capacity discipline are strong.
max_size_mbExplicit valueHard cap in place.Prevents runaway queries from consuming all filesystem space.
max_size_mbVery low relative to current sizeFile is close to its hard cap.Next growth attempt will fail; raise or plan capacity.
type_descLOGDependsLog file.Judge in context of recovery model and log reuse reasons.
type_descROWSDependsPrimary or secondary data file.Size and growth matter equally.
size_mbVery small with fixed growthControlled initial size.Normal for new databases.
size_mbVery small with percentage growthNext few autogrows will be tiny events firing often.Normalize to fixed MB growth.

Check log reuse wait reasons

Immediately after the file-size check, as the second Phase 10 query. It is typically triggered by unexplained log-file growth, ACTIVE_TRANSACTION reports, backup validation, or investigation of long-running transactions on a FULL recovery database. Single T-SQL session, VIEW ANY DEFINITION, read-only. Instantaneous snapshot — capture twice if needed to tell transient from persistent. Expose why each database’s transaction log cannot currently reuse its oldest inactive VLFs, so log growth can be attributed to the correct cause (missing backup, open transaction, replication lag, CDC, mirroring/AG sync, etc.) rather than treated as a generic “log is full” incident.

log_reuse_wait_desc is the human-readable version of log_reuse_wait. The value is instantaneous: the same description can be completely normal (NOTHING during idle, ACTIVE_TRANSACTION during a short online index rebuild) or a serious problem (LOG_BACKUP on a FULL-recovery database that has never been log-backed).

FieldSourceType / UnitMeaning
namesys.databases.namesysnameDatabase name.
recovery_model_descsys.databases.recovery_model_descnvarchar(60)SIMPLE, BULK_LOGGED, or FULL. Controls whether log backups are required for log reuse.
log_reuse_wait_descsys.databases.log_reuse_wait_descnvarchar(60)Current reason the log cannot be truncated. Finite domain — see the value table below.

Finite domain of log_reuse_wait_desc:

ValueMeaningTypical cause
NOTHINGNo blocker; the log can reuse inactive VLFs on the next checkpoint.Healthy steady state.
CHECKPOINTWaiting for a checkpoint to run.Transient; rarely requires action.
LOG_BACKUPA log backup has not been taken since the last truncation point, and recovery model is FULL or BULK_LOGGED.Missing log backup job, disabled backup, backup device failure.
ACTIVE_BACKUP_OR_RESTOREA backup or restore is in progress right now.Normal during backup windows; abnormal if stuck.
ACTIVE_TRANSACTIONAn open transaction contains the oldest active log record.Long-running transaction, orphan session, open BEGIN TRAN without commit.
DATABASE_MIRRORINGMirroring redo lag.Deprecated; applies to legacy mirroring only.
REPLICATIONTransactional replication log reader has not harvested all committed transactions yet.Replication agent stopped or behind.
DATABASE_SNAPSHOT_CREATIONA database snapshot is being created.Transient.
LOG_SCANA log scan is in progress.Transient.
AVAILABILITY_REPLICASecondary replica has not hardened or redone log records.AG secondary offline, network partition, slow redo.
OLDEST_PAGEAn indirect checkpoint is in progress and the oldest dirty page has not yet flushed.Normal for databases with TARGET_RECOVERY_TIME > 0.
OTHER / XTP_CHECKPOINTIn-memory OLTP (Hekaton) checkpoint work.Normal on memory-optimized tables.

Check why each database log can or cannot currently reuse inactive log space.

SELECT
    name,
    recovery_model_desc,
    log_reuse_wait_desc
FROM sys.databases
ORDER BY name;
namerecovery_model_desclog_reuse_wait_desc
codex_tde_demoFULLLOG_BACKUP
masterSIMPLENOTHING
modelFULLNOTHING
msdbSIMPLENOTHING
stoxxFULLNOTHING
stoxx_backupFULLNOTHING
stoxx_dbFULLNOTHING
tempdbSIMPLENOTHING

The single actionable row is codex_tde_demo, which is in FULL recovery with log_reuse_wait_desc = LOG_BACKUP. That combination means the database has been set to full recovery but has never had a log backup taken since — so the transaction log will grow unboundedly, waiting for a backup that never comes. The fix is one of two operational decisions: either set up a real log backup job (mandatory for any production FULL-recovery database), or explicitly switch the database to SIMPLE recovery if point-in-time recovery is not required. Every other database on this instance shows NOTHING, which is the target state for an idle audit window. stoxx, stoxx_backup, and stoxx_db are all FULL recovery but with clear reuse chains, which indicates their log backup history is current. tempdb is always SIMPLE and always healthy from a reuse standpoint by design.

ColumnValueWatchMeaningImplication
log_reuse_wait_descNOTHINGNo current blocker to log reuse.Healthy steady state.
log_reuse_wait_descCHECKPOINTDependsWaiting for checkpoint to run.Usually transient.
log_reuse_wait_descACTIVE_TRANSACTIONDependsAn open transaction is preventing truncation.Investigate only if persistent or paired with log growth pressure; check sys.dm_tran_database_transactions.
log_reuse_wait_descLOG_BACKUP with FULL recoveryLog cannot truncate until backup occurs.Backup discipline problem — verify jobs, devices, and credentials.
log_reuse_wait_descACTIVE_BACKUP_OR_RESTOREDependsBackup or restore in flight.Expected during backup windows; problematic if stuck.
log_reuse_wait_descREPLICATION❌ if persistentLog reader has not caught up.Check the log reader agent health.
log_reuse_wait_descAVAILABILITY_REPLICA❌ if persistentAG secondary is behind.Check secondary replica sync state.
recovery_model_descFULL with NOTHINGLog reuse chain is flowing correctly.Backups are running; recovery model is aligned with policy.
recovery_model_descFULL with LOG_BACKUPNo log backup history.Either set up backups or switch to SIMPLE.
recovery_model_descSIMPLE with ACTIVE_TRANSACTIONDependsOpen transaction is holding the log.Will self-resolve on commit/rollback or checkpoint.

Phase 11 | Security Quick Check

Performance audits frequently expose security drift at the same time: overly broad sysadmin membership, enabled sa, or guest access patterns that should be explicit decisions. This phase is intentionally short and focused on fast, high-signal checks.

Privilege surface

Review current sysadmin membership

At the start of Phase 11, and whenever any privileged-change request is investigated. It is typically triggered by baseline hardening review, suspected unauthorized access, post-incident forensic review, or routine quarterly privilege audit. Single T-SQL session, VIEW ANY DEFINITION or sysadmin, read-only against sys.server_principals and sys.server_role_members. List every principal currently in the sysadmin server role. The audit goal is not merely enumeration; it is to decide whether each principal should still have that level of privilege.

FieldSourceType / UnitMeaning
login_namesys.server_principals.namesysnameLogin name — SQL login, Windows login, or Windows group name.
type_descsys.server_principals.type_descnvarchar(60)SQL_LOGIN, WINDOWS_LOGIN, WINDOWS_GROUP, SERVER_ROLE, CERTIFICATE_MAPPED_LOGIN, ASYMMETRIC_KEY_MAPPED_LOGIN, EXTERNAL_LOGIN, EXTERNAL_GROUP.
is_disabledsys.server_principals.is_disabledbit1 = login is disabled and cannot authenticate; 0 = enabled.

List all principals that currently belong to the sysadmin server role.

SELECT
    p.name         AS login_name,
    p.type_desc,
    p.is_disabled
FROM sys.server_principals AS p
JOIN sys.server_role_members AS rm
  ON p.principal_id = rm.member_principal_id
JOIN sys.server_principals AS r
  ON rm.role_principal_id = r.principal_id
WHERE r.name = 'sysadmin'
ORDER BY p.name;
login_nametype_descis_disabled
BUILTIN\AdministratorsWINDOWS_GROUP0
NT AUTHORITY\NETWORK SERVICEWINDOWS_LOGIN0
saSQL_LOGIN0

This is a real hardening concern on three counts. First, sa is enabled — modern SQL Server guidance is to disable sa entirely, or at minimum rename it and enforce a strong rotation policy. Every brute-force and SQL-injection attempt eventually tries sa, and an enabled sa with a known username is the largest single credential-stuffing target on the instance. Second, BUILTIN\Administrators is in sysadmin. This means any local Windows administrator on the host — including service accounts, remote desktop operators, and the support team — implicitly has full control of SQL Server. Microsoft stopped adding BUILTIN\Administrators to sysadmin automatically after SQL Server 2008, and it is generally considered best practice to remove it in favor of a dedicated DBA security group. Third, NT AUTHORITY\NETWORK SERVICE is sysadmin, which is unusually broad for a service identity and suggests a legacy service account attachment rather than an intentional design. A production performance audit should not stop at pure query tuning when the privilege surface is this broad, because operational risk and unauthorized-change risk are both elevated. The remediation is staged: create a dedicated DBA Windows group, grant it sysadmin, remove BUILTIN\Administrators, demote or remove NETWORK SERVICE, and disable sa.

ColumnValueWatchMeaningImplication
type_descSQL_LOGINDependsSQL-authenticated login.sa or other SQL logins at sysadmin level deserve explicit review.
type_descWINDOWS_GROUPDependsGroup-based privilege.Broad group membership can expand sysadmin access beyond what operators intend. Nested groups are not expanded by this query.
type_descWINDOWS_LOGINDependsIndividual Windows principal.Service identities at sysadmin level should be justified explicitly.
type_descCERTIFICATE_MAPPED_LOGIN / ASYMMETRIC_KEY_MAPPED_LOGINDependsCertificate- or key-based login.Often used for module signing; review the certificate owner and module trust chain.
login_namesa❌ when enabledDefault SQL-authenticated sysadmin.Disable entirely, or at minimum rename and enforce rotation.
login_nameBUILTIN\AdministratorsAll local Windows admins implicitly sysadmin.Remove in favor of a dedicated DBA Windows group.
login_nameNT AUTHORITY\SYSTEMDependsLocal system account.Required by some SQL Server services; remove only with testing.
login_nameNT SERVICE\MSSQLSERVER or NT SERVICE\SQLAgent$<instance>Service identity for SQL Server itself or SQL Agent.Expected and required.
is_disabled0❌ for unneeded privileged principalsPrincipal is enabled right now.It can authenticate immediately with sysadmin rights.
is_disabled1DependsPrincipal is disabled.Lower immediate exposure, though membership should still be justified and removed if unused.

Check whether guest has CONNECT in the current database

After the sysadmin review, as the second Phase 11 query. It is typically triggered by database-level hardening audit, investigation of anonymous access reports, routine privilege review. Single T-SQL session, run in the target database (USE stoxx;), VIEW DEFINITION or owner privileges on sys.database_permissions, read-only. Determine whether the built-in guest user currently has CONNECT permission in this database. guest is the mechanism SQL Server uses to let any authenticated login access a database without an explicit user mapping; on most application databases, that is exactly the wrong behavior.

guest is created automatically in every database and cannot be dropped, but its CONNECT permission can be revoked (and is revoked by default in user databases since SQL Server 2005). The master and tempdb system databases intentionally keep guest CONNECT enabled and should not be modified.

FieldSourceType / UnitMeaning
guest_connect_grantedEXISTS over sys.database_permissions joined to sys.database_principalsint, 0 or 11 = guest has CONNECT granted or grant-with-grant-option in the current database. 0 = CONNECT is revoked.

The inner EXISTS expression reads:

FieldSourceType / UnitMeaning
dp.grantee_principal_idsys.database_permissions.grantee_principal_idintPrincipal the permission is granted to.
pr.namesys.database_principals.namesysnameDatabase principal name — filtered to guest.
dp.permission_namesys.database_permissions.permission_namenvarchar(128)Permission name — filtered to CONNECT.
dp.statesys.database_permissions.statechar(1)G = granted, W = granted with grant option, R = revoked, D = denied.

Check whether the guest principal currently has CONNECT permission in stoxx.

USE stoxx;
SELECT
    CASE
        WHEN EXISTS (
            SELECT 1
            FROM sys.database_permissions AS dp
            JOIN sys.database_principals  AS pr
              ON dp.grantee_principal_id = pr.principal_id
            WHERE pr.name            = 'guest'
              AND dp.permission_name = 'CONNECT'
              AND dp.state IN ('G', 'W')
        ) THEN 1
        ELSE 0
    END AS guest_connect_granted;
guest_connect_granted
0

This is the correct result for a normal application database. guest exists as a database principal (as it must), but it is not allowed to connect implicitly: any login that wants to use stoxx needs an explicit user mapping or database-level role membership. That removes one common database-level hardening concern from the report. For a full hardening pass, this same query should be executed against every user database on the instance — the guest default is revoked in user databases since SQL Server 2005, but it is still occasionally re-granted by legacy scripts, third-party installers, or accidental GRANT CONNECT TO guest statements. master and tempdb intentionally keep guest.CONNECT granted and must be left alone.

ColumnValueWatchMeaningImplication
guest_connect_granted0guest does not have CONNECT in this database.Normal hardened state for a user database.
guest_connect_granted1 in a user databaseguest can connect implicitly.Review immediately unless the database has a specific documented need. Revoke with REVOKE CONNECT FROM guest;.
guest_connect_granted1 in master or tempdbDefault behavior of system databases.Do not change — these system databases rely on guest for normal operation.

Phase 12 | Compile the Report

The audit is only useful if it ends in a prioritized report. Each finding should state what was observed, how strong the evidence is, and what the next action should be. Separate confirmed problems from limited-history observations so the reader can act on the former without blocking on the latter.


flowchart LR
    E["Evidence"] --> P["Priority"]
    P --> A["Action"]
    E -->|Phase 1-11 DMVs| P
    P -->|High| A1["Do this week"]
    P -->|Medium| A2["Do this month"]
    P -->|Low| A3["Next maintenance window"]
    P -->|Informational| A4["No action, monitor"]
    A1 --> R["Prioritized report to stakeholders"]
    A2 --> R
    A3 --> R
    A4 --> R

Prioritized findings

AreaFindingEvidencePriorityNext action
BaselineCore instance defaults are not production-tuned.MAXDOP = 0 on a 16-CPU host; cost threshold for parallelism = 5; max server memory = 2147483647 (uncapped); optimize for ad hoc workloads = 0.HighSet explicit memory cap leaving ~20% for the OS, raise cost threshold to 25-50, set MAXDOP to 8 for starters, and enable optimize for ad hoc workloads.
Confidence boundaryDMV history is very short; cumulative phases reflect startup aftermath rather than workload.uptime_days = 0 / uptime_hours = 5.HighRe-run Phases 3, 5, 6, 8, and 9 after a full business cycle before making long-term workload conclusions.
MemoryNo live memory pressure.PLE = 18103 s; zero pending memory grants; buffer pool 480 MB of uncapped 22705 MB target.InformationalDo not treat memory as a bottleneck now. Re-check after warm-up.
WaitsCumulative waits are dominated by recent backups and isolated lock waits, not workload pressure.LCK_M_U = 42.27% from 11 demo tasks; BACKUPTHREAD + BACKUPIO = 18.71%; no CXPACKET / PAGEIOLATCH_* in top 10.Informational for nowRecapture after steady-state uptime before drawing any parallelism or blocking conclusion.
I/OStorage latency is excellent.Every file < 1 ms average read and write; stoxx.mdf at 0.50 / 0.28 ms across 48658 reads.InformationalDo not blame storage first. Used as a baseline for future comparisons.
IndexesModerate nonclustered fragmentation on three small silver OHLCV symbol_date indexes.40-46% fragmentation on 212-239-page indexes.LowBundle into normal maintenance; not urgent.
StatisticsReal stale statistics on dbo.gold_daily_summary.modification_counter = 14674 and 8602 on a 506-row table; last_updated = 2026-03-29.HighRun UPDATE STATISTICS dbo.gold_daily_summary WITH FULLSCAN and verify affected plans.
Plan cacheSingle-use ad hoc plans dominate the Adhoc cache.564 single-use plans consuming 78.60 MB; ~95% of the total ad hoc footprint.MediumEnable optimize for ad hoc workloads and review client-side query construction for parameterization gaps.
TempDBLayout is structurally correct but dangerously undersized.8 equal data files, fixed 64 MB growth, but each file is only 8 MB.MediumPre-grow every tempdb data file to match expected peak workload (typical: 1-4 GB per file).
BlockingNo live user blocking.sys.dm_exec_requests returned no active user rows.InformationalRe-run on demand when users report slowness.
DeadlocksA single deadlock has been observed since startup.Number of Deadlocks/sec = 1.MediumCollect the system_health xml_deadlock_report via sys.fn_xe_file_target_read_file before drawing conclusions.
File growthUser databases are healthy; system databases still use percentage growth.stoxx, stoxx_backup, stoxx_db all fixed-MB growth with explicit caps on stoxx_db; MSDBData still at 10%.MediumNormalize master and msdb growth increments to fixed MB values.
Log reusecodex_tde_demo is in FULL recovery with LOG_BACKUP as the reuse blocker.log_reuse_wait_desc = LOG_BACKUP on a demo database with no log backup history.HighEither configure log backups or switch the database to SIMPLE recovery explicitly.
SecurityPrivilege surface is too broad.sa enabled; BUILTIN\Administrators and NT AUTHORITY\NETWORK SERVICE both sysadmin.HighCreate a dedicated DBA Windows group, grant it sysadmin, remove BUILTIN\Administrators, demote NETWORK SERVICE, disable sa.
Guest accessguest does not have CONNECT in stoxx.guest_connect_granted = 0.InformationalBaseline healthy.

SQL Server Performance Audit Playbook References

Phase 1 — Instance baseline

Phase 2 — Memory and buffer pool

Phase 3 — Wait statistics

Phase 4 — I/O performance

Phase 5 — Cached statements

Phase 6 — Index health

Phase 7 — TempDB health

Phase 8 — Blocking and deadlocks

Phase 9 — Statistics and plan cache

Phase 10 — Database files and log reuse

Phase 11 — Security quick check