SQL Server CPU utilization high: telling query load apart from a bad plan

Your monitoring says the SQL Server host is at 98% CPU. Before you page anyone or start killing sessions: SQL Server is designed to use available CPU. A cold buffer pool after restart, backup compression, an ETL window, or a well-parallelized reporting query will all legitimately pin CPU at 90%+. High CPU is a symptom with no severity attached until you answer two questions: who is burning the CPU (SQL Server or something else on the host), and is the work useful (throughput) or wasted (a bad plan, a compilation storm, or spinlock contention).

The inverse case is easier to miss and usually worse: low CPU with high query latency. That points at blocking, I/O starvation, or memory-grant waits, none of which show up in a CPU graph. If you only alert on CPU percentage, you will page on healthy systems and sleep through real outages.

This article walks the split: SQL Server CPU versus other-process CPU, legitimate load versus a bad plan, and the in-engine signals (runnable backlog, signal wait ratio, SOS_SCHEDULER_YIELD) that separate “busy” from “starved.”

What this means

SQL Server runs its own cooperative scheduler (SQLOS), one scheduler per logical CPU, each with a run queue. OS CPU utilization tells you time was consumed; it does not tell you whether queries are queuing for CPU time. The in-engine equivalent of a CPU queue is runnable_tasks_count in sys.dm_os_schedulers, and the aggregate measure of scheduler pressure is signal_wait_time_ms in sys.dm_os_wait_stats: time a thread spent on the runnable queue after its resource became available. A thread that got its page, got its lock, and then waited for a CPU is pure CPU pressure.

Three distinct situations all present as “high CPU”:

  1. Legitimate load. Batch requests/sec is high, throughput is healthy, waits are unremarkable. CPU is doing useful work. Cold starts, backup compression, ETL, and index rebuilds live here.
  2. A bad plan or inefficient queries. One or a few queries consume disproportionate worker time: nested loops over large sets, missing indexes forcing scans, a parameter-sniffed plan that is catastrophically wrong for typical values. CPU is high but throughput per unit of CPU is poor.
  3. Not CPU at all, or not SQL Server’s CPU. Another process on the host (antivirus, backup agent) is competing, or a hypervisor is throttling the VM. CPU steal is invisible inside a Windows guest; what you see is SOS_SCHEDULER_YIELD waits with oddly low reported CPU.

The diagnostic flow:

flowchart TD
    A[Host CPU high] --> B{SQL Server process CPU high?}
    B -- No --> C[Find competing process: AV, backup agent]
    B -- Yes --> D{Runnable backlog / signal waits elevated?}
    D -- No --> E[Busy but not starved: check if maintenance or ETL window]
    D -- Yes --> F{Top queries by total_worker_time concentrated?}
    F -- Few queries dominate --> G[Bad plan / missing index / parameter sniffing]
    F -- Broad spread --> H[Legitimate load or compilation storm: check compilations ratio]
    G --> I[Query Store plan history: did the plan change?]

Common causes

CauseWhat it looks likeFirst thing to check
Legitimate peak load (OLTP busy hour, ETL, backup compression)High CPU, high batch requests/sec, no runnable backlog, waits normalBatch requests/sec vs baseline; is this a known maintenance window?
Missing indexes / scan-heavy plansHigh CPU, high logical reads on top queries, elevated PAGEIOLATCH waitsTop queries by total_worker_time in sys.dm_exec_query_stats
Parameter sniffing regressionSudden CPU/latency jump for one query, query text unchanged, plan changedQuery Store plan history; does the query run fast with OPTION (RECOMPILE)?
Compilation storm (ad-hoc, non-parameterized SQL)High CPU, compilations/sec near batch requests/secSQL Compilations/sec to Batch Requests/sec ratio
Competing process on host (AV, backup agent)Total CPU high, SQL Server process CPU lowRing buffer split of sql_cpu_pct vs other_cpu_pct
VM CPU steal / co-tenant contentionSOS_SCHEDULER_YIELD waits high, signal wait ratio high, reported CPU oddly lowSignal wait ratio in sys.dm_os_wait_stats
Spinlock contentionVery high CPU with low useful throughput, waits unremarkablesys.dm_os_spinlock_stats
Cold start / warmupHigh CPU and physical reads right after restart, settles over minutes to hoursInstance uptime; page life expectancy (PLE) climbing from zero

Quick checks

All read-only. Run them in order; each narrows the branch.

-- 1. Split SQL Server CPU from other-process CPU (last ~256 minutes, ~1 sample/min).
-- NOTE: sys.dm_os_ring_buffers is documented by Microsoft but flagged
-- "future compatibility is not guaranteed." Brittle XML, diagnostic-only.
-- Do not build your only CPU monitor on it.
DECLARE @ts BIGINT = (SELECT cpu_ticks / (cpu_ticks / ms_ticks) FROM sys.dm_os_sys_info);
SELECT TOP 30
    DATEADD(ms, -1 * (@ts - [timestamp]), GETDATE()) AS sample_time,
    record.value('(./Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]', 'int') AS sql_cpu_pct,
    100 - record.value('(./Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'int')
        - record.value('(./Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]', 'int') AS other_cpu_pct
FROM (
    SELECT [timestamp], CONVERT(xml, record) AS record
    FROM sys.dm_os_ring_buffers
    WHERE ring_buffer_type = N'RING_BUFFER_SCHEDULER_MONITOR'
) AS x
ORDER BY [timestamp] DESC;
-- 2. Is there a runnable backlog? (in-engine CPU queue)
SELECT scheduler_id, runnable_tasks_count, current_tasks_count,
       active_workers_count, work_queue_count
FROM sys.dm_os_schedulers
WHERE status = 'VISIBLE ONLINE'
ORDER BY runnable_tasks_count DESC;
-- 3. Signal wait ratio: > 20% of total wait time indicates CPU pressure.
-- Cumulative since startup: snapshot twice and compute deltas for current behavior.
SELECT
    SUM(signal_wait_time_ms) AS signal_wait_ms,
    SUM(wait_time_ms) AS total_wait_ms,
    CAST(100.0 * SUM(signal_wait_time_ms) / SUM(wait_time_ms) AS DECIMAL(5,2)) AS signal_pct
FROM sys.dm_os_wait_stats
WHERE waiting_tasks_count > 0;
-- 4. Top CPU consumers since plans were cached.
-- total_worker_time is microseconds; for parallel queries it sums across
-- threads, so it can exceed elapsed time. That is normal, not a bug.
SELECT TOP 20
    SUBSTRING(qt.text, (qs.statement_start_offset/2)+1,
        ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(qt.text)
          ELSE qs.statement_end_offset END - qs.statement_start_offset)/2)+1) AS query_text,
    qs.execution_count,
    qs.total_worker_time / qs.execution_count / 1000.0 AS avg_cpu_ms,
    qs.total_elapsed_time / qs.execution_count / 1000.0 AS avg_elapsed_ms,
    qs.total_logical_reads / qs.execution_count AS avg_logical_reads
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
ORDER BY qs.total_worker_time DESC;
-- 5. Compilation pressure: ratio above ~10% of batch requests deserves a look.
-- Both counters are cumulative; compute rates between two samples.
SELECT counter_name, cntr_value
FROM sys.dm_os_performance_counters
WHERE counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Compilations/sec')
  AND object_name LIKE '%SQL Statistics%';

How to diagnose it

  1. Confirm the split. Run check 1. If SQL Server CPU is low and other-process CPU is high, stop touching SQL Server and find the process. Antivirus scanning database files and backup agents are the usual suspects; SQL Server data, log, and backup directories should be excluded from real-time AV scanning. Also check the host power plan: a processor throttled by a power-saving plan can show 100% CPU on a workload that normally uses 30%.
  2. Confirm actual scheduler pressure. Run checks 2 and 3. If runnable_tasks_count is 0 on all schedulers and signal waits are under 20%, the box is busy but not starved. Compare batch requests/sec against your baseline; if throughput matches the CPU curve, this is likely legitimate load. Go home, but capture the baseline.
  3. Check whether the load is expected. Cold start (buffer pool warming, plan cache empty, compilations spiking right after restart), backup compression, ETL windows, and index maintenance all legitimately drive CPU to 90%+. If the timing matches a maintenance schedule and waits are unremarkable, annotate it and move on.
  4. Rank queries by worker time. Run check 4. If a few statements dominate total_worker_time, you have a query problem, not a capacity problem. High avg_logical_reads on those queries points at scans from missing indexes. This DMV only covers plans currently in cache; stats are lost on eviction, restart, recompilation, or DBCC FREEPROCCACHE, so a “quiet” DMV during the incident does not exonerate a query that has already been evicted.
  5. Test for a bad cached plan. If one query regressed suddenly and its text has not changed, suspect parameter sniffing. Run it with OPTION (RECOMPILE) in a test context; if it runs fast with a fresh compile, the cached plan is the problem. On SQL 2016+, check Query Store for a plan change coinciding with the regression. Query Store is enabled per database and is on by default only for new databases in SQL 2022+; older instances need it enabled explicitly.
  6. Check compilations. Run check 5. Compilations/sec sustained above roughly 10-20% of batch requests/sec with matching CPU pressure indicates plan cache inefficiency: non-parameterized ad-hoc SQL, plan eviction under memory pressure, or overused OPTION (RECOMPILE) hints.
  7. If CPU is high, waits are unremarkable, and throughput is poor, look at spinlock contention via sys.dm_os_spinlock_stats. Spinlock burns are invisible to wait stats by design; the classic signature is near-100% CPU with almost no useful work completing. Microsoft has published fixes for specific spinlock hot spots (for example, plan cache bucket contention on ad-hoc workloads); treat trace-flag mitigations as vendor-guided changes, not self-serve tuning.
  8. On a VM, suspect the hypervisor. High SOS_SCHEDULER_YIELD waits and a rising signal wait ratio with modest reported CPU is the steal-time signature. SQL Server cannot see co-tenant contention; escalate to whoever owns the host.

Metrics and signals to monitor

SignalWhy it mattersWarning sign
SQL CPU vs other-process CPU splitTells you whether to tune queries or chase another processOther-process CPU sustained high alongside SQL slowness
runnable_tasks_count per schedulerThe in-engine CPU queue; better than OS CPU% for scheduler pressureSustained > 1 per scheduler; > 5 is significant contention
signal_wait_time_ms as % of total waitsDirect measure of threads that had their resource but waited for CPU> 20% of total wait time
SOS_SCHEDULER_YIELD waitsWorkers yielding because run queues are longBecoming a dominant wait type
Top queries by total_worker_timeIdentifies the actual CPU consumersFew queries dominating; sudden change after a deploy
SQL Compilations/sec to Batch Requests/sec ratioCompilation is CPU-intensive; high ratio means the plan cache is not helping> 10% investigate, > 20% with CPU pressure act
Batch Requests/sec vs baselineSeparates “more work” from “wasted work”CPU up while batch requests flat or down

Alerting guidance, stated plainly: CPU percentage alone must never page. Cold starts, backup compression, intentionally small instances, and ETL jobs all legitimately hit 90%+. Ticket when SQL Server CPU sustains above 70%. Page only on a composite: CPU > 90% sustained, plus elevated runnable backlog per scheduler, plus query timeouts or throughput collapse, plus no evidence the load is an expected batch or maintenance pattern.

Fixes

Legitimate load outgrowing capacity

If throughput tracks CPU and there is no waste, you are at capacity. Short term: confirm with the composite signals above so you do not “fix” a healthy system. Longer term: establish time-of-day and day-of-week baselines for batch requests/sec so the next growth step is visible weeks out, and schedule CPU-heavy maintenance (backups with compression, index rebuilds, CHECKDB) away from peak windows.

Bad plan or missing indexes

  • Add the missing index or rewrite the scan-heavy query. This is the highest-leverage fix for CPU-bound workloads; a nested-loops plan over a large set burns orders of magnitude more CPU than the right seek.
  • For a confirmed parameter-sniffing regression on SQL 2016+: force the known-good plan in Query Store with sp_query_store_force_plan. Alternatively, evict the specific bad plan with DBCC FREEPROCCACHE(<plan_handle>). Do not run bare DBCC FREEPROCCACHE on a production instance; clearing the entire plan cache forces a compile storm on top of your CPU incident.
  • Longer term: consider OPTIMIZE FOR hints, or redesign procedures that serve wildly different parameter distributions from one cached plan.

Compilation storm

  • Parameterize ad-hoc SQL at the application layer, or evaluate forced parameterization per database (which itself can introduce parameter sniffing; test).
  • Enable the optimize for ad hoc workloads server option so single-use plans store only a stub on first execution.
  • Check for plan cache eviction from memory pressure: if PLE and plan cache size are dropping together, the root cause is memory, not CPU.

Competing process

  • Exclude SQL Server data, log, and backup file paths from real-time antivirus scanning.
  • Move backup agents, SSIS, and other heavy processes off the database host or into maintenance windows.
  • Set the host power plan to High Performance; power-managed CPU throttling distorts every CPU reading you take.

VM or host-level contention

  • Escalate with evidence: the signal wait ratio and SOS_SCHEDULER_YIELD trend are your proof that threads are ready but not getting CPUs. No amount of query tuning fixes steal time.

Prevention

  • Baselines before thresholds. Capture batch requests/sec, CPU split, and top-query worker time by time of day and day of week. Without a baseline you cannot distinguish a retry storm from a healthy traffic peak, and you cannot prove a post-deploy regression.
  • Persist DMV data externally. sys.dm_exec_query_stats, wait stats, and ring buffers reset or rotate. After a restart you have no forensics. Snapshot wait stats every 30-60 seconds and compute deltas; a single cumulative read of sys.dm_os_wait_stats reflects the entire uptime and is nearly useless for current-state diagnosis.
  • Enable Query Store on production databases (SQL 2016+). It is the difference between “the plan changed at 14:02, here is the diff” and guessing after a restart.
  • Make alerting maintenance-aware. Backup compression, CHECKDB, and index rebuilds will trigger any naive CPU alert. Suppress or annotate during known windows rather than disabling CPU monitoring entirely.
  • Baseline power and host config. Dedicated hosts on High Performance power plan, AV exclusions in place, and no co-located heavy processes. These remove the most common “not SQL Server’s fault” branches before the incident starts.

How Netdata helps

  • SQL Server vs OS CPU correlation: Netdata charts per-process CPU alongside host CPU, so the “SQL high + other low” versus “SQL low + other high” split is visible without querying ring buffers mid-incident.
  • Scheduler pressure signals: runnable backlog, signal wait ratio, and SOS_SCHEDULER_YIELD waits next to the CPU curve show whether the box is busy or actually starved, which is the distinction CPU percentage cannot make.
  • Wait statistics as a time series: periodic snapshots with deltas, so you see current wait behavior rather than cumulative-since-startup noise.
  • Throughput context: batch requests/sec and compilations/sec on the same dashboard as CPU, making “more work” versus “wasted work” a visual judgment instead of a query you have to remember at 3 a.m.
  • ML anomaly detection on CPU-adjacent signals: flags when compilations, waits, or throughput deviate from learned baselines, catching regressions that fixed thresholds miss during legitimate peaks.

Netdata’s Microsoft SQL Server monitoring brings these signals together with per-second metrics and ML anomaly detection.