Skip to main content

How to Monitor Disk I/O Usage in Batch Script

Disk I/O (Input/Output) usage is a critical performance metric. A system might have low CPU and plenty of RAM, but if the hard drive is at 100% active time, every action will feel slow as the system waits for the disk to read or write data. Monitoring disk I/O helps you identify disk thrashing, failing drives, or applications that are performing excessive write operations. By querying performance counters through PowerShell, you can track the real-time activity of your drives.

This guide will explain how to capture and monitor disk performance in Batch.

Why PowerShell for Disk I/O Monitoring

Disk I/O (Input/Output) monitoring in pure Batch faces the same problems as other performance monitoring:

  1. wmic is deprecated since Windows 10 21H1, and its output contains invisible \r characters that corrupt Batch variables, causing numeric comparisons like if %usage% GTR 80 to fail with syntax errors.
  2. Performance counter values require proper typing: Disk throughput values can be floating-point numbers (e.g., 15.73 MB/s), which Batch cannot handle.
  3. Counter names are localized: \LogicalDisk(*)\% Disk Time is translated on non-English Windows. PowerShell's Get-Counter uses these localized names, while Get-CimInstance uses language-independent WMI class names.

All methods in this guide use PowerShell for queries and comparisons, with Batch as the orchestrator.

Method 1: Current Disk Activity Check

This method provides a quick snapshot of how busy each disk is, showing the percentage of time the disk is actively processing requests, current throughput, and queue depth.

@echo off
setlocal

set "Drive=%~1"

if "%Drive%"=="" (
echo Usage: %~nx0 ^<drive_letter:^>
echo.
echo Examples:
echo %~nx0 C:
echo %~nx0 D:
endlocal
exit /b 1
)

echo [INFO] Checking disk activity for %Drive%...

powershell -NoProfile -Command ^
"try {" ^
" $disk = Get-CimInstance Win32_PerfFormattedData_PerfDisk_LogicalDisk" ^
" -Filter \"Name='%Drive%'\" -ErrorAction Stop;" ^
" if (-not $disk) { Write-Host 'ERROR: Drive %Drive% not found.'; exit 2 };" ^
" $readMBs = [math]::Round($disk.DiskReadBytesPerSec / 1MB, 1);" ^
" $writeMBs = [math]::Round($disk.DiskWriteBytesPerSec / 1MB, 1);" ^
" Write-Host '';" ^
" Write-Host \" Drive: %Drive%\";" ^
" Write-Host \" Active Time: $($disk.PercentDiskTime)%%\";" ^
" Write-Host \" Read Speed: $readMBs MB/s\";" ^
" Write-Host \" Write Speed: $writeMBs MB/s\";" ^
" Write-Host \" Read IOPS: $($disk.DiskReadsPerSec)\";" ^
" Write-Host \" Write IOPS: $($disk.DiskWritesPerSec)\";" ^
" Write-Host \" Queue Depth: $($disk.CurrentDiskQueueLength)\";" ^
" Write-Host '';" ^
" if ([int]$disk.PercentDiskTime -gt 80) {" ^
" Write-Host ' [WARNING] Disk activity is high!' -ForegroundColor Yellow;" ^
" exit 1" ^
" } else {" ^
" Write-Host ' [OK] Disk activity is normal.';" ^
" exit 0" ^
" }" ^
"} catch {" ^
" Write-Error $_.Exception.Message;" ^
" exit 2" ^
"}"

set "PSResult=%errorlevel%"

if %PSResult% equ 2 (
echo [ERROR] Could not query disk performance. >&2
)

endlocal
exit /b %PSResult%

Metrics explained:

MetricWMI PropertyWhat It Means
Active Time (%)PercentDiskTimePercentage of time the disk is processing requests. 100% = saturated.
Read/Write SpeedDiskReadBytesPerSec / DiskWriteBytesPerSecCurrent throughput in bytes/second (converted to MB/s).
Read/Write IOPSDiskReadsPerSec / DiskWritesPerSecNumber of read/write operations per second.
Queue DepthCurrentDiskQueueLengthNumber of requests waiting. Values consistently above 2 indicate a bottleneck.

Why Get-CimInstance instead of Get-Counter:

Get-Counter uses localized counter paths (\LogicalDisk(C:)\% Disk Time in English, different on non-English systems). Get-CimInstance Win32_PerfFormattedData_PerfDisk_LogicalDisk uses language-independent WMI class names that work on any Windows locale.

Method 2: Continuous I/O Dashboard with CSV Logging

For diagnosing intermittent disk performance issues (e.g., "the server is slow every day at 2 PM"), use a continuous monitoring loop that logs timestamped readings.

@echo off
setlocal enabledelayedexpansion

set "Drive=%~1"
set "Interval=5"

if "%Drive%"=="" (
echo Usage: %~nx0 ^<drive_letter:^>
echo Example: %~nx0 C:
endlocal
exit /b 1
)

:: Extract just the letter for the filename
set "DriveLetter=%Drive:~0,1%"
set "LogFile=%~dp0disk_io_%DriveLetter%.csv"
set "TempOut=%temp%\disk_io_out.txt"

title Disk I/O Monitor: %Drive% - Logging every %Interval%s

:: Write CSV header if file is new
if not exist "%LogFile%" (
echo "Timestamp","ActivePct","ReadMBs","WriteMBs","ReadIOPS","WriteIOPS","QueueDepth" > "%LogFile%"
)

echo [MONITOR] Drive: %Drive% Interval: %Interval%s Log: %LogFile%
echo [MONITOR] Press Ctrl+C to stop.
echo --------------------------------------------------

:: Define the PowerShell command in a variable
set "PSCmd=$d = Get-CimInstance Win32_PerfFormattedData_PerfDisk_LogicalDisk -Filter \"Name='%Drive%'\" -ErrorAction SilentlyContinue; if (-not $d) { Write-Output 'ERROR,0,0,0,0,0,0'; exit }; $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'; $rMB = [math]::Round($d.DiskReadBytesPerSec / 1MB, 1); $wMB = [math]::Round($d.DiskWriteBytesPerSec / 1MB, 1); Write-Output \"$ts,$($d.PercentDiskTime),$rMB,$wMB,$($d.DiskReadsPerSec),$($d.DiskWritesPerSec),$($d.CurrentDiskQueueLength)\""

:IOLoop
:: STEP 1: Run PowerShell and save output to a temporary file.
:: This prevents the Batch 'FOR' loop from trying to parse the PowerShell syntax.
powershell -NoProfile -Command "%PSCmd%" > "%TempOut%"

:: STEP 2: Read the temporary file using the FOR loop.
for /f "usebackq tokens=1-7 delims=," %%a in ("%TempOut%") do (
set "TS=%%a"
set "Active=%%b"
set "RdMB=%%c"
set "WrMB=%%d"
set "RdIO=%%e"
set "WrIO=%%f"
set "Queue=%%g"
)

if "%TS%"=="ERROR" (
echo [%date% %time%] Drive %Drive% not found or inaccessible. >&2
goto :IOWait
)

:: Display on screen
echo [%TS%] Active: %Active%%% Read: %RdMB% MB/s Write: %WrMB% MB/s IOPS: %RdIO%R/%WrIO%W Queue: %Queue%

:: Log to CSV
echo "%TS%","%Active%","%RdMB%","%WrMB%","%RdIO%","%WrIO%","%Queue%" >> "%LogFile%"

:IOWait
timeout /t %Interval% >nul
goto :IOLoop

Sample console output:

[2024-05-10 14:32:05] Active: 12% Read: 5.3 MB/s Write: 1.2 MB/s IOPS: 145R/32W Queue: 0
[2024-05-10 14:32:10] Active: 98% Read: 0.1 MB/s Write: 45.7 MB/s IOPS: 8R/1247W Queue: 4
[2024-05-10 14:32:15] Active: 35% Read: 12.8 MB/s Write: 3.4 MB/s IOPS: 312R/89W Queue: 1

How to interpret the data:

  • Active Time consistently above 80%: The disk is saturated. This is the primary bottleneck indicator.
  • High IOPS with low throughput: Many small random I/O operations (common with databases, antivirus scans). An SSD upgrade would help.
  • High throughput with low IOPS: Large sequential operations (backups, file copies). Usually temporary and expected.
  • Queue depth consistently above 2: Requests are waiting because the disk cannot keep up. Combined with high active time, this confirms a bottleneck.

Method 3: Physical Disk vs. Logical Disk Monitoring

If a physical disk is partitioned into multiple logical drives (C: and D: on the same SSD), monitoring individual logical drives may not show the complete picture. This method monitors the physical disk hardware.

@echo off
setlocal

echo [INFO] Physical disk I/O summary:
echo.

powershell -NoProfile -Command ^
"$disks = Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk |" ^
" Where-Object { $_.Name -ne '_Total' };" ^
"if (-not $disks) { Write-Host 'No physical disks found.'; exit 1 };" ^
"$disks | ForEach-Object {" ^
" [PSCustomObject]@{" ^
" 'Disk' = $_.Name;" ^
" 'Active (%%)' = $_.PercentDiskTime;" ^
" 'Read (MB/s)' = [math]::Round($_.DiskReadBytesPerSec / 1MB, 1);" ^
" 'Write (MB/s)' = [math]::Round($_.DiskWriteBytesPerSec / 1MB, 1);" ^
" 'Queue' = $_.CurrentDiskQueueLength" ^
" }" ^
"} | Format-Table -AutoSize"

endlocal
exit /b 0

When to use physical vs. logical monitoring:

ScenarioUse
Monitoring a specific data volume's loadLogical disk (Methods 1–2)
Diagnosing hardware bottlenecksPhysical disk (Method 3)
Single partition per diskEither (results are equivalent)
Multiple partitions on one diskPhysical disk (shows true hardware load)

Understanding physical disk names:

Physical disk names in WMI look like 0 C: D: (disk number followed by the logical drives it contains). This tells you which physical hardware each partition lives on.

How to Avoid Common Errors

Wrong Way: Using wmic Output Directly in Batch Comparisons

:: BROKEN: wmic output contains invisible \r characters
for /f "skip=1" %%a in ('wmic path Win32_PerfFormattedData_PerfDisk_LogicalDisk where "Name='C:'" get PercentDiskTime') do set "usage=%%a"
if %usage% GTR 80 echo High disk usage

The variable usage contains 45\r (with an invisible carriage return), causing the if %usage% GTR 80 comparison to fail with a syntax error.

Correct Way: Use Get-CimInstance in PowerShell, which returns clean typed data and handles the comparison internally.

Problem: Drive Name Format in WMI

WMI expects drive names as C: (letter and colon). Using C:\ (with a trailing backslash) causes an "Invalid query" error because the backslash is interpreted as an escape character in WQL.

Solution: Ensure your drive variable uses the X: format. If accepting user input, strip any trailing backslash:

set "Drive=%Drive:\=%"

Problem: Counter Names Are Localized

Get-Counter paths like \LogicalDisk(C:)\% Disk Time are translated on non-English Windows (e.g., German: \Logischer Datenträger(C:)\Prozent Datenträgerzeit).

Solution: Use Get-CimInstance Win32_PerfFormattedData_PerfDisk_LogicalDisk (as shown in all methods), which uses language-independent WMI class names.

Wrong Way: Using fsutil for Performance Monitoring

fsutil manages filesystem structures (quotas, sparse files, reparse points) but provides no real-time utilization or throughput data.

Correct Way: Use Get-CimInstance with the Win32_PerfFormattedData_PerfDisk_* classes.

Best Practices and Rules

1. Always Use "Formatted" (Cooked) Performance Data

WMI provides two versions of performance data: Win32_PerfRawData_* (raw counters requiring complex formulas) and Win32_PerfFormattedData_* (pre-calculated, human-readable values). Always use the Formatted classes, they provide ready-to-use percentages and rates.

2. Monitor Queue Depth, Not Just Active Time

Active time alone can be misleading, an SSD at 100% active time may still be performing well if the queue is empty. Queue depth consistently above 2 is a more reliable indicator of a genuine bottleneck.

3. Distinguish Physical from Logical

If C: and D: share the same physical disk, their combined load determines whether the hardware is bottlenecked. Monitor the physical disk (Method 3) for hardware-level troubleshooting.

4. Use Appropriate Intervals

  • Quick diagnostic: A single reading (Method 1)
  • Spike hunting: 3–5 second intervals (Method 2)
  • Trend analysis: 30–60 second intervals logged over hours

5. Consider typeperf for Lightweight Logging

For simple CSV logging without Batch logic, typeperf writes directly to a file:

typeperf "\LogicalDisk(C:)\%% Disk Time" "\LogicalDisk(C:)\Disk Bytes/sec" -si 5 -o disk_perf.csv

Note: counter paths are localized (see Common Errors).

Conclusions

Monitoring disk I/O provides deep visibility into a critical performance layer that CPU and RAM monitoring cannot reveal. By tracking active time, throughput, IOPS, and queue depth through PowerShell and WMI, you can identify whether a disk is the bottleneck, and whether the issue is sequential throughput, random I/O, or simple saturation. This diagnostic capability is essential for optimizing file-intensive workloads and maintaining responsive systems under load.