IO_COMPLETION Wait Stats: Spills, Sorts and File Growth

IO_COMPLETION wait stats measure queries waiting on disk work that isn’t a normal data page read. The cause I see most is a sort or hash join that ran out of memory and spilled to tempdb. The fix is usually a better estimate, not a faster disk.

Casey clears a small patch of counter for a cake for twelve, sixty guests show up, and Jesse ends up frosting half the cake down in the basement. In the last panel a frosting-covered Jesse sits by the lopsided cake while Casey takes the next cake order on the phone: "Cake for twelve? Lovely. Now, how many are really coming?"

This post is part of my wait stats series, told as one story at the Clipboard Diner. Every post is listed in the series guide.

Night 10 at the Clipboard Diner

Casey replaced the stairway bulb first thing Friday, and the basement trips got faster. Then the phone rang. A caller wanted a birthday sheet cake for twelve, ready at eight.

Casey cleared a patch of counter sized for a cake for twelve. That’s how the kitchen works: you claim the space before you start, and you don’t get more halfway through. At 7:30 the birthday crowd rolled in. It was sixty people, the whole county fair committee.

Jesse baked a cake for sixty, but the patch of counter fit a cake for twelve. So Jesse did the only thing left. Half the cake went down the stairs to the folding table in the basement. Jesse frosted a row downstairs, climbed up, frosted a row upstairs, and went back down. The potatoes and the cooler had nothing to do with it. This was Jesse’s own work, done in the slow place.

Meanwhile Casey built a new shelf by the register, because Pat’s order books had filled the old one. It was a small shelf, so it went up in minutes. A big one would need a coat of paint first, and nothing goes on wet paint.

The cake was late but beautiful, and the clipboard got a note for next time: Cake for 12 meant 60. Ask how many are coming.

What IO_COMPLETION Means

That’s a spill. IO_COMPLETION is SQL Server waiting for disk work to finish when that work isn’t a normal data page read. Data page reads are PAGEIOLATCH Wait Stats, the trips for ingredients. IO_COMPLETION is the other disk work, like Jesse frosting in the basement.

In my health checks, the common sources are sorts and hash joins that spill to tempdb. Reading the transaction log during recovery or a rollback shows here too. So does some of the work of growing a file.

Here’s how a spill happens. Before a query runs, SQL Server gives it a memory grant. That’s a fixed amount of memory for its sorts and hashes. The size comes from the estimated row count. Don’t expect an ordinary sort or hash to get more once it starts. When the real rows don’t fit, the sort writes part of its data to tempdb and reads it back later.

IO_COMPLETION, what it is: Sort or hash needs memory, then grant too small for real rows, then spill to tempdb, read it back, then query carries on. The time is lost at "Spill to tempdb, read it back". Normal: Small totals, or log reads during recovery; Watch: Rises as files grow many times a day; Act: Rises with reports, plans show spills.

Disk is far slower than memory, so a spilled sort takes much longer than one that fits. The most common root cause is the estimate. The cake order said twelve. Stale statistics, functions wrapped around columns and tricky filters all make estimates wrong.

You could say more server memory would stop spills. Fair point for a huge query that hits the cap on grant size. For a bad estimate, it won’t help. The grant comes from the estimate. A query that expects twelve rows asks for a small grant on any server.

File growth is the other reason this wait is in the title. Growing a file is slow disk work outside data pages. The time can land here, in ASYNC_IO_COMPLETION Wait Stats, or in a PREEMPTIVE wait. It depends on the file and the step. That’s Casey’s shelf.

Normal or a Problem?

SituationWhat it meansWhat to do
Small IO_COMPLETION totals, nothing users feelBackground log reads and small jobs.Normal. Leave it.
High for a while after a restart or failoverRecovery is reading the log.Normal. It settles once recovery ends.
Rises with big reports or loads, and plans show spill warningsSorts and hashes spill to tempdb.Find the spilling queries and fix the estimates.
Rises when files grow many times a dayMany small growths during busy hours.Pre-size files and set a fixed growth size.

See It on Your Server

The first query reads IO_COMPLETION and its neighbor from tomorrow’s post, so you can see which one carries the time.

-- Which non-data-page I/O wait carries the time since the last restart?
SELECT wait_type,
       waiting_tasks_count AS waits,
       CAST(wait_time_ms / 1000.0 AS decimal(18, 1)) AS waited_sec,
       CAST(1.0 * wait_time_ms / NULLIF(waiting_tasks_count, 0) AS decimal(12, 1)) AS avg_wait_ms
FROM sys.dm_os_wait_stats
WHERE wait_type IN (N'IO_COMPLETION', N'ASYNC_IO_COMPLETION')
ORDER BY wait_time_ms DESC;

A rising IO_COMPLETION during report hours points to spills. Measure that window with Wait Stats Over Time instead of trusting totals since the restart.

The second query finds the statements that spilled the most. It turns total_spills, which counts 8 KB pages, into megabytes. It needs SQL Server 2016 SP2, SQL Server 2017 CU3 or later.

-- Which cached statements spill the most to tempdb, in total and per run?
SELECT TOP (10)
       qs.execution_count AS runs,
       CAST(qs.total_spills * 8 / 1024.0 AS decimal(18, 1)) AS spilled_mb,
       CAST(qs.total_spills * 8 / 1024.0 / qs.execution_count AS decimal(18, 1)) AS spilled_mb_per_run,
       CAST(qs.total_grant_kb / 1024.0 / qs.execution_count AS decimal(18, 1)) AS grant_mb_per_run,
       stmt.statement_text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS t
CROSS APPLY (SELECT SUBSTRING(t.text, qs.statement_start_offset / 2 + 1,
                 (IIF(qs.statement_end_offset = -1, DATALENGTH(t.text), qs.statement_end_offset)
                  - qs.statement_start_offset) / 2 + 1) AS statement_text) AS stmt
WHERE qs.total_spills > 0
ORDER BY qs.total_spills DESC;

A spilled_mb_per_run far bigger than grant_mb_per_run is a strong hint that the grant was sized for twelve. Take the top statement and open its actual execution plan. A sort or hash operator with a spill warning is your cake in the basement. Compare its estimated rows with its actual rows to see how far off the guess was. If the estimate is close, look at row width, grant limits and memory pressure instead.

How to fix IO_COMPLETION, in order: 1. Find spilling statements, check plan; 2. Fix the estimate: update statistics; 3. Sort less with an ordered index; 4. Enterprise: let grant feedback learn; 5. Put tempdb on fast storage; 6. Pre-size files, fixed growth. Check first: Spill totals per statement.

Fix It

  1. Find the spilling statements with the query above, and confirm the spill in the actual plan.
  2. Fix the estimate: update statistics, and remove functions wrapped around filtered columns.
  3. Sort less. An index that returns rows in order can remove the sort entirely.
  4. On Enterprise edition, let memory grant feedback learn. It raises the grant on later runs of a query that spilled. Row mode queries need compatibility level 150 or higher.
  5. Put tempdb on fast storage. Check its rows in the file latency query from the PAGEIOLATCH post.
  6. Pre-size data and log files, and use fixed growth sizes instead of tiny ones.

A common shortcut is a hint that forces a bigger memory grant. It fixes that one report. Then the data grows, dozens of copies run at once, and they all wait for memory instead. The RESOURCE_SEMAPHORE Wait Stats post covers that mess. Fix the estimate first.

New in SQL Server 2022 and 2025

SQL Server 2022 changed log growth. Log autogrowth up to 64 MB now uses instant file initialization, so the new space isn’t zeroed first. That works in all editions, and each growth of 64 MB or less creates one virtual log file. The 64 MB default log growth isn’t new, it dates from SQL Server 2016. Now that default growth skips the zeros.

SQL Server 2022 also improved memory grant feedback. Percentile mode looks at a query’s past grants instead of only the last one. Persistence keeps the feedback in Query Store, so it survives when the plan leaves the cache. Both help repeating queries stop spilling. They need Enterprise edition, compatibility level 140 or higher and Query Store in READ_WRITE mode.

Related Reading

The Clipboard Diner, a wait stats series. Previous: PAGEIOLATCH Wait Stats: Waiting for Data From Disk. Next: ASYNC_IO_COMPLETION Wait Stats: Large File Operations. Every post is listed in the series guide.

Next comes the night truck, due at 3 AM to box up the whole cooler.

A spill is not a disk problem, it is work that didn’t fit the memory it was given.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Memory, SQL Scripts, SQL TempDB, SQL Wait Stats
Previous Post
PAGEIOLATCH Wait Stats: Waiting for Data From Disk
Next Post
ASYNC_IO_COMPLETION Wait Stats: Large File Operations

Related Posts

2 Comments. Leave new

  • I had situation with this wait type when I tried to backuped batabase on Tape. My Tape library have just two mount points for tape. I had this wait type because both mount points were occupated.

    Reply
  • I believe the page life expectancy metric is 300 or higher

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.