SQL Server SOS_SCHEDULER_YIELD waits: CPU scheduler pressure explained
When SOS_SCHEDULER_YIELD dominates sys.dm_os_wait_stats, the reflex is to assume CPU pressure and start hunting for bad queries. That reflex is right about half the time. The other half, you are chasing a signal that is doing exactly what it was designed to do: recording every time a worker voluntarily yielded its 4ms quantum because other runnable workers were queued.
The wait type has existed in SQL Server since the SQLOS era and the 4ms quantum is fixed in every version. It cannot be tuned. What you can tune is your interpretation. A workload doing efficient set-based scans of pages already in memory will yield constantly and rack up enormous SOS_SCHEDULER_YIELD numbers without anything being wrong. A VM on an oversubscribed host reports the same wait type while the hypervisor silently steals CPU cycles SQL Server cannot see.
This article covers the mechanism, the signals that separate benign from pathological yielding, the production patterns that drive the pathological case (missing indexes, parameter sniffing, compilation storms, VM steal, soft-NUMA surprises), and the diagnostic queries that take you from “SOS_SCHEDULER_YIELD is high” to “here is the plan consuming the scheduler.”
What this means
SQL Server runs its own cooperative, non-preemptive scheduler through SQLOS. There is one scheduler per logical CPU. Each scheduler has three lists: running (the worker currently on CPU), runnable (workers ready to run, queued for their turn), and waiter (workers blocked on a resource such as I/O, a lock, or memory). Workers are not preempted by the OS. They run until they hit a yield point, at which point SQLOS checks whether the 4ms quantum has been exhausted.
When a worker exhausts its quantum and the runnable queue is nonempty, the worker goes to the bottom of the runnable queue. The time it spends there, waiting to get back on CPU, is recorded as SOS_SCHEDULER_YIELD. Two properties follow that operators must internalize:
- Resource wait is always zero.
wait_time_ms - signal_wait_time_msfor SOS_SCHEDULER_YIELD is always 0. The worker is not waiting on a resource. The entire wait is signal wait: time spent runnable, queued for a CPU. - Count alone is meaningless. A high
waiting_tasks_countwith single-digit-millisecond average signal wait is a healthy busy system. The same count with 50ms or higher average signal wait is scheduler saturation.
This is why the signal wait ratio matters more than the raw wait time. If signal_wait_time_ms across all wait types exceeds roughly 20% of total wait_time_ms, the engine is spending a significant fraction of its wait time queued for CPU. On a VM, that number can climb while OS-reported CPU utilization stays low, because the cycles being waited for were stolen by a co-tenant or consumed by hypervisor overhead.
Common causes
| Cause | What it looks like | First thing to check |
|---|---|---|
| Real CPU-bound workload | Top waits dominated by SOS_SCHEDULER_YIELD, signal wait ratio above 20%, runnable_tasks_count sustained above 1 per scheduler, SQL Server CPU at 80%+ | sys.dm_exec_query_stats ordered by total_worker_time |
| VM CPU steal or co-tenant contention | Same wait profile but SQL Server CPU reports low (30-50%), other process CPU also low, no obvious query culprit | Hypervisor CPU ready (%RDY on VMware, steal in /proc/stat on Linux hosts, CPU credits on Azure burstable VMs) |
| Missing indexes forcing scans | SOS_SCHEDULER_YIELD rising with specific queries, large total_logical_reads on a few plans, often CXPACKET as a co-wait on parallel scans | sys.dm_exec_query_stats ordered by total_logical_reads, missing index DMVs |
| Parameter sniffing bad plan | Sudden spike after plan recompilation, one plan shape (often nested loops on a large set) burning CPU, query duration regressed | Query Store plan regression, or compare plans in sys.dm_exec_query_plan |
| Compilation storm | SOS_SCHEDULER_YIELD correlated with SQL Compilations/sec tracking Batch Requests/sec, plan cache churning | Compilations-to-batch-requests ratio, single-use plan count |
| Auto soft-NUMA on large systems | Severe SOS_SCHEDULER_YIELD accumulation on systems with 9 or more cores per socket, low CPU utilization, limited parallel workloads | sys.dm_os_nodes node type, soft-NUMA configuration |
| CPU power management | Erratic SOS_SCHEDULER_YIELD correlated with CPU frequency transitions, often on idle-to-burst traffic | OS power plan, BIOS C-states and P-states |
| Edition core limit | SOS_SCHEDULER_YIELD on Standard or Express when workload exceeds licensed CPU capacity, SQL Server CPU capped below host CPU | Edition and licensing, scheduler count vs host CPU |
Quick checks
-- Snapshot wait stats delta over 60 seconds (read-only)
DECLARE @t1 TABLE (wait_type nvarchar(60), wait_time_ms bigint, signal_wait_time_ms bigint, waiting_tasks_count bigint);
INSERT @t1 SELECT wait_type, wait_time_ms, signal_wait_time_ms, waiting_tasks_count
FROM sys.dm_os_wait_stats WHERE wait_type = 'SOS_SCHEDULER_YIELD';
WAITFOR DELAY '00:00:60';
SELECT
s.wait_type,
s.waiting_tasks_count - t.waiting_tasks_count AS new_yields,
s.wait_time_ms - t.wait_time_ms AS new_wait_ms,
s.signal_wait_time_ms - t.signal_wait_time_ms AS new_signal_ms
FROM sys.dm_os_wait_stats s
JOIN @t1 t ON t.wait_type = s.wait_type
WHERE s.wait_type = 'SOS_SCHEDULER_YIELD';
-- Signal wait ratio across all waits (read-only)
SELECT
SUM(signal_wait_time_ms) AS total_signal_ms,
SUM(wait_time_ms) AS total_wait_ms,
CAST(100.0 * SUM(signal_wait_time_ms) / NULLIF(SUM(wait_time_ms), 0) AS DECIMAL(5,2)) AS signal_pct
FROM sys.dm_os_wait_stats
WHERE wait_type NOT IN (
'SLEEP_TASK','BROKER_TO_FLUSH','BROKER_TASK_STOP','CLR_AUTO_EVENT','CLR_MANUAL_EVENT',
'LAZYWRITER_SLEEP','SQLTRACE_BUFFER_FLUSH','WAITFOR','XE_TIMER_EVENT','XE_DISPATCHER_WAIT',
'FT_IFTS_SCHEDULER_IDLE_WAIT','BROKER_EVENTHANDLER','SP_SERVER_DIAGNOSTICS_SLEEP',
'HADR_FILESTREAM_IOMGR_IOCOMPLETION','DIRTY_PAGE_POLL','DISPATCHER_QUEUE_SEMAPHORE',
'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP','QDS_ASYNC_QUEUE','CHECKPOINT_QUEUE',
'REQUEST_FOR_DEADLOCK_SEARCH','LOGMGR_QUEUE','ONDEMAND_TASK_QUEUE','HADR_WORK_QUEUE',
'BROKER_TRANSMITTER','KSOURCE_WAKEUP');
-- Per-scheduler runnable queue depth (read-only)
SELECT scheduler_id, cpu_id, current_tasks_count,
runnable_tasks_count, active_workers_count, work_queue_count
FROM sys.dm_os_schedulers
WHERE status = 'VISIBLE ONLINE'
ORDER BY runnable_tasks_count DESC;
How to diagnose it
The flow is: confirm the wait is real, find where the CPU is going, then decide whether the cause is inside or outside SQL Server.
flowchart TD
A["SOS_SCHEDULER_YIELD dominates waits"] --> B{"signal_wait_time_ms
above 20% of total?"}
B -- No --> C["Benign yielding
No action needed"]
B -- Yes --> D{"runnable_tasks_count above 0
across schedulers?"}
D -- No --> E["Check non-yielding scheduler
or measurement window"]
D -- Yes --> F{"SQL Server CPU high?"}
F -- Yes --> G["Real CPU pressure
Find top worker_time queries"]
F -- No --> H["VM steal or external contention
Check hypervisor CPU ready"]
G --> I["Missing index, bad plan,
or compilation storm"]
H --> J["Resize, relocate, DRS anti-affinity"]Snapshot wait stats and compute deltas.
sys.dm_os_wait_statsis cumulative since startup, or since the lastDBCC SQLPERF('sys.dm_os_wait_stats', CLEAR). A single read tells you nothing about current state. Snapshot, wait 30 to 60 seconds, snapshot again, compute the delta. Do not runDBCC SQLPERF(..., CLEAR)on a production instance unless you accept losing the cumulative history.Check the signal wait ratio. In the delta window, compute
SUM(signal_wait_time_ms) / SUM(wait_time_ms). Above 0.20 means the engine is spending more than 20% of its wait time runnable, queued for CPU. Below 0.10 with high SOS_SCHEDULER_YIELD counts is benign yielding. The ratio is more reliable than any absolute threshold on the wait type itself.Check runnable queue depth. Sustained
runnable_tasks_countabove 1 across multiple schedulers is CPU pressure. Anywork_queue_countabove 0 is worker thread exhaustion: a different and more urgent problem. Checkmax_worker_threadsagainstcurrent_tasks_countand look forTHREADPOOLwaits alongside.Find the queries consuming CPU.
-- Top queries by CPU (read-only, cumulative since plan cached)
SELECT TOP 20
qs.sql_handle, qs.plan_handle, qs.execution_count,
qs.total_worker_time / 1000 AS total_cpu_ms,
qs.total_worker_time / qs.execution_count / 1000 AS avg_cpu_ms,
qs.total_elapsed_time / qs.execution_count / 1000 AS avg_elapsed_ms,
SUBSTRING(qt.text, 1, 200) AS query_text
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;
The gap between avg_cpu_ms and avg_elapsed_ms shows wait time. Queries where the two are close are CPU-bound. Queries where elapsed is much larger than CPU are waiting on something else and are not your SOS_SCHEDULER_YIELD culprit.
- Find live requests currently yielding. Queries incurring SOS_SCHEDULER_YIELD do not appear in
sys.dm_os_waiting_tasks, because the worker is runnable, not waiting on a resource. Querysys.dm_exec_requestsand filter onlast_wait_type. A running request’slast_wait_typeis the wait it last completed, not necessarily the wait it is currently on.
-- Live requests currently yielding (read-only)
SELECT r.session_id, r.status, r.wait_type,
r.last_wait_type, r.wait_time,
r.cpu_time, r.logical_reads,
SUBSTRING(qt.text, 1, 200) AS query_text
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) qt
WHERE r.last_wait_type = 'SOS_SCHEDULER_YIELD'
ORDER BY r.cpu_time DESC;
If SQL Server CPU is low and the wait is high, suspect the VM. This is the pattern that catches teams off guard. The hypervisor is not giving SQL Server the cycles it expects, so workers exhaust their quantum without actually getting 4ms of CPU time. Check VMware
%RDY(sustained above 5-10% per vCPU is worth investigating; above 10% is significant contention ),stealtime on Linux KVM hosts, or Azure VM CPU credits and quota. Also check host power management, because aggressive C-state transitions cause the same artifact.On SQL 2016 or later with a large socket count, check soft-NUMA. Auto soft-NUMA is enabled by default on systems with more than 8 physical cores per socket and has been documented to cause severe SOS_SCHEDULER_YIELD accumulation on large systems running limited parallel workloads. Check
sys.dm_os_nodesand evaluate whether disabling auto soft-NUMA changes the profile. The configuration change requires a service restart.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
SOS_SCHEDULER_YIELD wait time, delta | Direct measure of time spent waiting to get back on CPU after yielding | Sustained growth above baseline, especially with throughput drop |
signal_wait_time_ms ratio | Separates CPU pressure from resource waits | Above 20% of total wait time |
runnable_tasks_count per scheduler | Cleanest in-engine CPU queue signal | Sustained above 1 across multiple schedulers |
work_queue_count per scheduler | Worker thread exhaustion indicator | Any sustained nonzero value |
| SQL Server CPU vs other CPU vs idle | Distinguishes SQL-bound from external contention | SQL CPU low plus wait high equals VM steal |
SQL Compilations/sec to Batch Requests/sec ratio | Compilation pressure | Above 10-20% sustained |
Batch Requests/sec trend | Workload context for interpreting waits | Drop while waits climb means throughput collapse |
Top queries by total_worker_time | Identifies queries burning CPU | Sudden change in ranking after deployment or stats update |
Fixes
Real CPU-bound queries (missing indexes, bad plans). This is the most common actionable cause. Use the top-worker-time query above to identify culprits, then inspect their plans. Missing indexes show up as large scans with high logical reads. Parameter sniffing regressions show up as plan changes visible in Query Store. Force the known-good plan in Query Store with sp_query_store_force_plan for immediate relief, then address the root cause (hint, statistics update, query rewrite, covering index). On SQL 2022, the Parameter Sensitive Plan optimization can address the most common parameter-sniffing pattern automatically.
Compilation storms. If SQL Compilations/sec tracks Batch Requests/sec at near 1:1, the plan cache is providing no benefit. Enable optimize for ad hoc workloads at the server level to stub single-use plans on first execution and cache the full plan only on second execution. Consider forced parameterization for workloads dominated by non-parameterized ad-hoc SQL. Address the source of the ad-hoc queries if possible.
VM steal. Not fixable inside SQL Server. Work with the virtualization team to reduce host oversubscription, configure DRS anti-affinity rules to keep noisy co-tenants away, size the VM so the vCPU-to-core ratio is 1:1 for SQL Server workloads, and verify power management is set to high performance at both host and guest levels.
Auto soft-NUMA surprises. On large systems with the symptom, test disabling auto soft-NUMA in a controlled change window. Measure CPU utilization and SOS_SCHEDULER_YIELD before and after. In the documented reproduction, disabling auto soft-NUMA took CPU utilization from 33% to 90% and eliminated the wait accumulation, because parallel query schedulers were no longer artificially partitioned.
SQL 2022 compilation regression. A documented case shows SQL 2022 producing significantly more SOS_SCHEDULER_YIELD waits during query compilation than SQL 2019 on identical hardware, with first execution taking 14 seconds on 2022 versus 2 seconds on 2019. As of early 2026 this remained under Microsoft support investigation. If you see SOS_SCHEDULER_YIELD concentrated on compile operations after upgrading, open a support case and reference the compilation-time regression.
Edition core limits. If Standard or Express edition is capping CPU below what the workload needs, no amount of query tuning will help. The fix is licensing or workload reduction.
Prevention
- Baseline the signal wait ratio. Track
SUM(signal_wait_time_ms) / SUM(wait_time_ms)as a regular time series. Most teams first notice CPU pressure after it is already impacting users; the signal ratio starts climbing hours or days earlier. - Snapshot wait stats on a 30 to 60 second cadence. Cumulative
sys.dm_os_wait_statshides current behavior. Without deltas you cannot tell whether SOS_SCHEDULER_YIELD is climbing now or whether it has been accumulating since the last restart three months ago. - Track the top-N queries by
total_worker_timeover time. Sudden changes in ranking are the earliest indicator of plan regression or workload shift. - On VMs, monitor hypervisor CPU ready time alongside SQL Server metrics. SQL Server cannot see CPU steal; the only in-engine symptom is SOS_SCHEDULER_YIELD with low reported CPU utilization.
- Keep power management on high performance. Aggressive C-state and P-state transitions cause the same artifact as VM steal and are easy to overlook.
- Validate after every SQL Server upgrade. Compilation behavior and scheduler logic have changed across versions. An upgrade that felt slow may have a SOS_SCHEDULER_YIELD fingerprint worth investigating before you start tuning queries.
How Netdata helps
- The SQL Server collector surfaces
sys.dm_os_wait_statsdeltas at per-second granularity, so SOS_SCHEDULER_YIELD shows up as a current trend rather than as a cumulative number that obscures recent behavior. signal_wait_time_msratio is computed and charted directly, which removes the most common misread of this wait type.runnable_tasks_countandwork_queue_countper scheduler are collected alongside CPU utilization, so real CPU pressure (high runnable, high SQL CPU) is distinguishable from VM steal (high runnable, low SQL CPU, low host CPU) in one view.- Wait stats correlate with
Batch Requests/sec,SQL Compilations/sec, and the top-query metrics, so a compilation storm or throughput collapse appears next to the wait signal. - Anomaly detection on the signal wait ratio and runnable queue depth surfaces slow-build CPU pressure before users report latency.
- Host-level CPU steal metrics sit alongside the SQL Server metrics where the hypervisor exposes them, making the VM-steal case diagnosable from the same dashboard.
Netdata’s Microsoft SQL Server monitoring with Netdata brings these signals together with per-second metrics and ML anomaly detection.
Related guides
- SQL Server user connections climbing: connection pool leaks and retry storms
- SQL Server CPU utilization high: telling query load apart from a bad plan
- SQL Server CXPACKET and CXCONSUMER waits: parallelism, MAXDOP, and what is actually wrong
- SQL Server Error 9002: the transaction log for the database is full
- SQL Server high compilations per second: plan cache pollution and CPU burn
- How Microsoft SQL Server actually works in production: a mental model for operators
- SQL Server log autogrow stall: why every write pauses while the log file grows
- SQL Server log backups missing: the full-recovery log that grows forever
- SQL Server log_reuse_wait_desc: why the transaction log will not truncate
- SQL Server transaction log percent used climbing toward full
- SQL Server max server memory: setting it so the OS and buffer pool both survive
- SQL Server Memory Grants Pending above zero: queries queued before they can run






