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:
wmicis deprecated since Windows 10 21H1, and its output contains invisible\rcharacters that corrupt Batch variables, causing numeric comparisons likeif %usage% GTR 80to fail with syntax errors.- Performance counter values require proper typing: Disk throughput values can be floating-point numbers (e.g.,
15.73 MB/s), which Batch cannot handle. - Counter names are localized:
\LogicalDisk(*)\% Disk Timeis translated on non-English Windows. PowerShell'sGet-Counteruses these localized names, whileGet-CimInstanceuses 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:
| Metric | WMI Property | What It Means |
|---|---|---|
| Active Time (%) | PercentDiskTime | Percentage of time the disk is processing requests. 100% = saturated. |
| Read/Write Speed | DiskReadBytesPerSec / DiskWriteBytesPerSec | Current throughput in bytes/second (converted to MB/s). |
| Read/Write IOPS | DiskReadsPerSec / DiskWritesPerSec | Number of read/write operations per second. |
| Queue Depth | CurrentDiskQueueLength | Number 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:
| Scenario | Use |
|---|---|
| Monitoring a specific data volume's load | Logical disk (Methods 1–2) |
| Diagnosing hardware bottlenecks | Physical disk (Method 3) |
| Single partition per disk | Either (results are equivalent) |
| Multiple partitions on one disk | Physical 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.