How to Compact a VHD File in Batch Script
Dynamic (Expandable) VHD/VHDX files grow as data is written to them, but they do NOT automatically shrink when data is deleted. If you stored 50 GB of files inside a dynamic VHD, then deleted 40 GB, the physical VHDX file on your host disk still takes up approximately 50 GB. Over time, this wasted space adds up significantly on backup servers and Hyper-V hosts. DiskPart's compact vdisk operation reclaims the unused space inside the virtual disk image and shrinks the physical file to only what's actually needed.
This guide will explain how to reclaim wasted space from VHD files.
How Dynamic VHD Space Works
Initial state: Write 50 GB: Delete 40 GB: After compact:
┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────┐
│ VHDX │ │ VHDX │ │ VHDX │ │ VHDX │
│ 2 MB │ │ 50 GB │ │ 50 GB │ │ ~12 GB │
│ (empty) │ │ (full) │ │ (10 used) │ │ (10 used)│
└──────────┘ └──────────┘ └───────────┘ └──────────┘
Physical file Physical file Physical file Physical file
grows as data is now 50 GB STILL 50 GB! shrunk by
is written Space not reclaimed compaction
The dynamic VHD grows on demand but never shrinks on its own. Compaction is the process of reclaiming blocks that were allocated for data that has since been deleted.
Compaction only applies to dynamic (expandable) VHDs. Fixed-size VHDs always occupy their full declared size on disk, the compact command runs but has zero effect. To check the VHD type, use detail vdisk in DiskPart after selecting the file.
Method 1: Basic VHD Compaction
The standard compaction process: attach the VHD in read-only mode, compact, and detach.
@echo off
setlocal
set "VHDPath=%~1"
if "%VHDPath%"=="" (
echo Usage: %~nx0 ^<path_to_vhd^>
echo.
echo Compacts a dynamic VHD/VHDX file to reclaim unused space.
echo.
echo Example: %~nx0 D:\VMs\DataStore.vhdx
endlocal
exit /b 1
)
:: Verify admin privileges
net session >nul 2>&1
if errorlevel 1 (
echo [ERROR] DiskPart requires administrator privileges. >&2
endlocal
exit /b 1
)
:: Verify the VHD file exists
if not exist "%VHDPath%" (
echo [ERROR] VHD file not found: %VHDPath% >&2
endlocal
exit /b 1
)
:: Record the file size before compaction
for %%f in ("%VHDPath%") do set "SizeBefore=%%~zf"
echo [INFO] Compacting: %VHDPath%
echo [INFO] Current file size: %SizeBefore% bytes
:: Check if it's a dynamic VHD
for /f "delims=" %%t in (
'powershell -NoProfile -Command ^
"$dp = ''%TEMP%\dp_detail_%RANDOM%.txt'';" ^
"(''select vdisk file=\"%VHDPath%\"'', ''detail vdisk'') | Set-Content $dp;" ^
"$out = diskpart /s $dp 2>&1;" ^
"Remove-Item $dp -ErrorAction SilentlyContinue;" ^
"if ($out -match ''Expandable|Dynamic'') { ''DYNAMIC'' }" ^
"elseif ($out -match ''Fixed'') { ''FIXED'' }" ^
"else { ''UNKNOWN'' }"'
) do set "VHDType=%%t"
if /i "%VHDType%"=="FIXED" (
echo [INFO] This is a fixed-size VHD. Compaction has no effect on fixed VHDs.
echo [INFO] Only dynamic (expandable^) VHDs can be compacted.
endlocal
exit /b 0
)
if /i "%VHDType%"=="UNKNOWN" (
echo [WARNING] Could not determine VHD type. Attempting compaction anyway...
)
echo [ACTION] Compacting VHD (this may take several minutes^)...
:::warning[Read-Only Attachment]
The VHD is attached as **read-only** during compaction to prevent data corruption. Do not attempt to write to the VHD while compaction is in progress.
:::
set "DPScript=%TEMP%\dp_compact_%RANDOM%.txt"
(
echo select vdisk file="%VHDPath%"
echo attach vdisk readonly
echo compact vdisk
echo detach vdisk
) > "%DPScript%"
diskpart /s "%DPScript%" >nul 2>&1
set "DPResult=%errorlevel%"
del "%DPScript%" 2>nul
if %DPResult% neq 0 (
echo [ERROR] Compaction failed. >&2
echo Common causes: >&2
echo - VHD is in use by Hyper-V or another process >&2
echo - VHD is already mounted >&2
echo - Insufficient permissions >&2
endlocal
exit /b 1
)
:: Measure the result
for %%f in ("%VHDPath%") do set "SizeAfter=%%~zf"
:: Calculate savings
for /f "delims=" %%s in (
'powershell -NoProfile -Command ^
"$before = %SizeBefore%;" ^
"$after = %SizeAfter%;" ^
"$saved = $before - $after;" ^
"$savedMB = [math]::Round($saved / 1MB, 1);" ^
"$pct = if ($before -gt 0) { [math]::Round($saved / $before * 100, 1) } else { 0 };" ^
"$beforeMB = [math]::Round($before / 1MB, 1);" ^
"$afterMB = [math]::Round($after / 1MB, 1);" ^
"Write-Output \"$beforeMB|$afterMB|$savedMB|$pct\""'
) do (
for /f "tokens=1-4 delims=|" %%a in ("%%s") do (
echo.
echo [OK] Compaction complete.
echo Before: %%a MB
echo After: %%b MB
echo Saved: %%c MB (%%d%%^)
)
)
:: Log the operation
for /f "delims=" %%t in (
'powershell -NoProfile -Command "Get-Date -Format ''yyyy-MM-dd HH:mm:ss''"'
) do echo [%%t] COMPACT: %VHDPath% Before: %SizeBefore% After: %SizeAfter% by %USERNAME% >> "%~dp0vhd_compact.log"
endlocal
exit /b 0
Why read-only attachment:
The attach vdisk readonly step is critical. During compaction, DiskPart reorganizes the internal block allocation table. If the VHD were mounted read-write and an application wrote data during this process, the result could be a corrupted virtual disk. Read-only attachment ensures no writes can occur.
What "compact vdisk" actually does:
- Scans the virtual disk's block allocation table.
- Identifies blocks that were allocated for data that has since been deleted (the blocks still exist but contain no valid file data).
- Removes these empty blocks from the VHDX file.
- Updates the allocation table to reflect the smaller file.
The data inside the VHD is not modified, only the empty blocks are removed from the physical container file.
Method 2: Deep Compaction (Zero-Fill + Compact)
Basic compaction only reclaims blocks that the VHD file system has explicitly marked as free. However, deleted file data often remains in the blocks as residual content, the file system marks the space as available but doesn't zero it out. These "dirty empty" blocks cannot be compacted because they still contain non-zero data.
For maximum space savings, zero-fill all free space inside the VHD first, then compact.
@echo off
setlocal
set "VHDPath=%~1"
set "DriveLetter=Z"
if "%VHDPath%"=="" (
echo Usage: %~nx0 ^<path_to_vhd^>
echo.
echo Performs deep compaction: zero-fills free space, then compacts.
echo Requires sdelete.exe (from Sysinternals^) or a similar zero-fill tool.
echo.
echo Example: %~nx0 D:\VMs\WorkStation.vhdx
endlocal
exit /b 1
)
net session >nul 2>&1
if errorlevel 1 (
echo [ERROR] Administrator privileges required. >&2
endlocal
exit /b 1
)
if not exist "%VHDPath%" (
echo [ERROR] VHD file not found: %VHDPath% >&2
endlocal
exit /b 1
)
:: Check for sdelete
where sdelete >nul 2>&1
if errorlevel 1 (
echo [ERROR] sdelete.exe not found in PATH. >&2
echo Download from: https://docs.microsoft.com/en-us/sysinternals/downloads/sdelete >&2
echo Place sdelete.exe in a directory on your PATH. >&2
endlocal
exit /b 1
)
:: Record initial size
for %%f in ("%VHDPath%") do set "SizeBefore=%%~zf"
echo ============================================================
echo DEEP VHD COMPACTION
echo ============================================================
echo.
echo File: %VHDPath%
for /f "delims=" %%s in (
'powershell -NoProfile -Command "[math]::Round(%SizeBefore% / 1MB, 1)"'
) do echo Size: %%s MB
echo.
echo Steps:
echo 1. Mount VHD (read-write^)
echo 2. Zero-fill free space with sdelete
echo 3. Detach VHD
echo 4. Compact VHD (read-only^)
echo.
echo ============================================================
:: Step 1: Mount the VHD (read-write, needed for zero-filling)
echo [1/4] Mounting VHD at %DriveLetter%:\...
set "DPScript=%TEMP%\dp_mount_%RANDOM%.txt"
(
echo select vdisk file="%VHDPath%"
echo attach vdisk
echo wait
echo select partition 1
echo assign letter=%DriveLetter%
) > "%DPScript%"
diskpart /s "%DPScript%" >nul 2>&1
del "%DPScript%" 2>nul
timeout /t 2 >nul
if not exist %DriveLetter%:\ (
echo [ERROR] Failed to mount VHD. >&2
endlocal
exit /b 1
)
echo [OK] VHD mounted at %DriveLetter%:\
:: Step 2: Zero-fill free space
echo [2/4] Zero-filling free space (this may take a long time^)...
echo [INFO] sdelete writes zeros to all unallocated space inside the VHD.
sdelete -z %DriveLetter%: -accepteula >nul 2>&1
if errorlevel 1 (
echo [WARNING] sdelete may not have completed fully. >&2
echo Proceeding with compaction anyway. >&2
)
echo [OK] Zero-fill complete.
:: Step 3: Detach the VHD
echo [3/4] Detaching VHD...
set "DPScript=%TEMP%\dp_detach_%RANDOM%.txt"
(
echo select vdisk file="%VHDPath%"
echo detach vdisk
) > "%DPScript%"
diskpart /s "%DPScript%" >nul 2>&1
del "%DPScript%" 2>nul
timeout /t 2 >nul
echo [OK] VHD detached.
:: Step 4: Compact
echo [4/4] Compacting VHD...
set "DPScript=%TEMP%\dp_compact_%RANDOM%.txt"
(
echo select vdisk file="%VHDPath%"
echo attach vdisk readonly
echo compact vdisk
echo detach vdisk
) > "%DPScript%"
diskpart /s "%DPScript%" >nul 2>&1
set "DPResult=%errorlevel%"
del "%DPScript%" 2>nul
if %DPResult% neq 0 (
echo [ERROR] Compaction failed. >&2
endlocal
exit /b 1
)
:: Measure savings
for %%f in ("%VHDPath%") do set "SizeAfter=%%~zf"
for /f "tokens=1-4 delims=|" %%a in (
'powershell -NoProfile -Command ^
"$b = %SizeBefore%; $a = %SizeAfter%; $s = $b - $a;" ^
"Write-Output \"$([math]::Round($b/1MB,1))|$([math]::Round($a/1MB,1))|$([math]::Round($s/1MB,1))|$(if($b -gt 0){[math]::Round($s/$b*100,1)}else{0})\""'
) do (
echo.
echo [OK] Deep compaction complete.
echo Before: %%a MB
echo After: %%b MB
echo Saved: %%c MB (%%d%%^)
)
for /f "delims=" %%t in (
'powershell -NoProfile -Command "Get-Date -Format ''yyyy-MM-dd HH:mm:ss''"'
) do echo [%%t] DEEP COMPACT: %VHDPath% Before: %SizeBefore% After: %SizeAfter% >> "%~dp0vhd_compact.log"
endlocal
exit /b 0
Why zero-filling makes a difference:
| Compaction Type | What It Reclaims | Typical Savings |
|---|---|---|
Basic (compact vdisk only) | Blocks marked as free by the file system | Moderate, depends on how the filesystem tracks free space |
| Deep (zero-fill + compact) | All blocks containing only zeros | Maximum, reclaims both free space and residual deleted file data |
When a file is deleted inside the VHD, the file system marks the space as available but the actual data bytes remain in the blocks. From the VHDX container's perspective, those blocks still contain data and cannot be removed. Zero-filling writes 0x00 to every free byte, making those blocks indistinguishable from never-used blocks, which compact vdisk can then remove.
sdelete.exe is part of the Sysinternals suite and is not included with Windows. You must download and place it in your PATH before using this method. The -z flag writes zeros (not the secure overwrite mode). The -accepteula flag suppresses the first-run license dialog.
Method 3: PowerShell Compaction (Hyper-V Environments)
For Hyper-V environments, PowerShell's Optimize-VHD cmdlet provides a simpler interface with additional options.
@echo off
setlocal
set "VHDPath=%~1"
set "Mode=%~2"
if "%VHDPath%"=="" (
echo Usage: %~nx0 ^<path_to_vhd^> [mode]
echo.
echo Modes:
echo Quick - Fast compaction (default^)
echo Full - Zero-detection + compaction (best savings^)
echo Retrim - Send TRIM commands to SSD-backed storage
echo Prezeroed - Compact assuming free space is already zeroed
echo.
echo Example: %~nx0 D:\VMs\Server.vhdx Full
endlocal
exit /b 1
)
if "%Mode%"=="" set "Mode=Full"
net session >nul 2>&1
if errorlevel 1 (
echo [ERROR] Administrator privileges required. >&2
endlocal
exit /b 1
)
if not exist "%VHDPath%" (
echo [ERROR] VHD file not found: %VHDPath% >&2
endlocal
exit /b 1
)
:: Check if Hyper-V PowerShell module is available
powershell -NoProfile -Command "Get-Command Optimize-VHD -ErrorAction SilentlyContinue | Out-Null; if (-not $?) { exit 1 }" >nul 2>&1
if errorlevel 1 (
echo [ERROR] Optimize-VHD cmdlet not available. >&2
echo This requires the Hyper-V PowerShell module. >&2
echo Install via: Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-Management-PowerShell >&2
echo Or use Method 1 (DiskPart^) which works without Hyper-V. >&2
endlocal
exit /b 1
)
:: Record size before
for %%f in ("%VHDPath%") do set "SizeBefore=%%~zf"
echo [ACTION] Compacting VHD with Optimize-VHD -Mode %Mode%...
echo [INFO] This may take several minutes for large files.
powershell -NoProfile -Command ^
"try {" ^
" Optimize-VHD -Path '%VHDPath%' -Mode %Mode% -ErrorAction Stop;" ^
" exit 0" ^
"} catch {" ^
" Write-Error $_.Exception.Message;" ^
" exit 1" ^
"}"
if errorlevel 1 (
echo [ERROR] Compaction failed. >&2
echo The VHD may be in use by a running VM. Shut down the VM first. >&2
endlocal
exit /b 1
)
:: Measure savings
for %%f in ("%VHDPath%") do set "SizeAfter=%%~zf"
for /f "tokens=1-3 delims=|" %%a in (
'powershell -NoProfile -Command ^
"$b = %SizeBefore%; $a = %SizeAfter%;" ^
"Write-Output \"$([math]::Round($b/1MB,1))|$([math]::Round($a/1MB,1))|$([math]::Round(($b-$a)/1MB,1))\""'
) do (
echo [OK] Compaction complete.
echo Before: %%a MB After: %%b MB Saved: %%c MB
)
endlocal
exit /b 0
Optimize-VHD modes explained:
| Mode | What It Does | Speed | Savings | Best For |
|---|---|---|---|---|
Quick | Reclaims blocks marked as free | Fast | Moderate | Regular maintenance |
Full | Detects zero-filled blocks + reclaims | Slow | Maximum | Monthly deep cleanup |
Retrim | Sends TRIM commands for SSD-backed storage | Fast | Varies | VHDs on SSD storage |
Prezeroed | Compact assuming free space already zeroed | Fast | Good | After manual zero-fill |
Optimize-VHD requires the Hyper-V PowerShell module (available even without the full Hyper-V role). DiskPart's compact vdisk works on any Windows system without additional features. Use DiskPart (Methods 1–2) for non-Hyper-V environments and Optimize-VHD (Method 3) where Hyper-V management tools are available.
Method 4: Batch Compaction of Multiple VHDs
For Hyper-V hosts with many dynamic VHDs, compact all of them in a single maintenance pass.
@echo off
setlocal EnableDelayedExpansion
set "VHDDir=%~1"
set "LogFile=%~dp0batch_compact.log"
if "%VHDDir%"=="" (
echo Usage: %~nx0 ^<directory_containing_vhds^>
echo.
echo Compacts all dynamic VHDX files in the specified directory.
echo.
echo Example: %~nx0 D:\VMs
endlocal
exit /b 1
)
net session >nul 2>&1
if errorlevel 1 (
echo [ERROR] Administrator privileges required. >&2
endlocal
exit /b 1
)
if not exist "%VHDDir%\" (
echo [ERROR] Directory not found: %VHDDir% >&2
endlocal
exit /b 1
)
echo [INFO] Scanning for VHDX files in %VHDDir%...
set "TotalFiles=0"
set "Compacted=0"
set "TotalSaved=0"
for /r "%VHDDir%" %%f in (*.vhdx *.vhd) do (
set /a "TotalFiles+=1"
set "FilePath=%%f"
set "FileName=%%~nxf"
:: Record size before
for %%s in ("%%f") do set "Before=%%~zs"
echo.
echo [!TotalFiles!] Compacting: !FileName!
:: Compact using DiskPart
set "DPScript=%TEMP%\dp_batchcompact_%RANDOM%.txt"
(
echo select vdisk file="!FilePath!"
echo attach vdisk readonly
echo compact vdisk
echo detach vdisk
) > "!DPScript!"
diskpart /s "!DPScript!" >nul 2>&1
set "DPResult=!errorlevel!"
del "!DPScript!" 2>nul
if !DPResult! equ 0 (
for %%s in ("%%f") do set "After=%%~zs"
for /f "delims=" %%m in (
'powershell -NoProfile -Command ^
"$saved = !Before! - !After!;" ^
"Write-Output ([math]::Round($saved / 1MB, 1))"'
) do set "SavedMB=%%m"
echo Saved: !SavedMB! MB
set /a "Compacted+=1"
for /f "delims=" %%t in (
'powershell -NoProfile -Command "Get-Date -Format ''yyyy-MM-dd HH:mm:ss''"'
) do echo [%%t] !FileName!: Before=!Before! After=!After! Saved=!SavedMB!MB >> "%LogFile%"
) else (
echo [SKIP] Failed (may be in use or fixed-size^)
)
)
echo.
echo ============================================================
echo Batch compaction complete.
echo Files processed: !TotalFiles!
echo Successfully compacted: !Compacted!
echo Log: %LogFile%
echo ============================================================
endlocal
exit /b 0
Scheduling monthly compaction:
Run Method 4 as a monthly scheduled task during off-peak hours (e.g., Sunday 2 AM). Dynamic VHDs accumulate wasted space gradually as VMs create and delete temporary files, install updates, and rotate logs. Monthly compaction prevents this waste from growing indefinitely.
VHD/VHDX files that are in use by running Hyper-V virtual machines cannot be compacted. DiskPart and Optimize-VHD will fail with a "file in use" error. Shut down or save-state all VMs before running batch compaction. Checkpointed VMs may also block compaction of their parent VHD.
How to Avoid Common Errors
Wrong Way: Compacting a Fixed-Size VHD
:: RUNS BUT HAS NO EFFECT, fixed VHDs cannot be compacted
select vdisk file="D:\VMs\FixedDisk.vhdx"
attach vdisk readonly
compact vdisk
Fixed VHDs always occupy their declared maximum size. Compaction has no effect because there are no dynamically allocated blocks to reclaim.
Correct Way: Method 1 checks the VHD type before attempting compaction and informs the user if it's fixed.
Wrong Way: Attaching Read-Write During Compaction
:: DANGEROUS: writes during compaction can corrupt the VHD
attach vdisk
compact vdisk
If the VHD is attached in read-write mode, background processes (indexing, antivirus) may write to the disk during compaction, potentially corrupting the virtual file system.
Correct Way: Always use attach vdisk readonly before compact vdisk.
Problem: VHD In Use by Hyper-V
A running VM locks its VHD files. DiskPart cannot select, attach, or compact a locked VHD.
Solution: Shut down the VM completely. A saved or paused VM still locks the VHD. Checkpoint (snapshot) chains also prevent compaction of the parent VHD, remove unnecessary checkpoints first.
Problem: Minimal Savings from Basic Compaction
If basic compaction (compact vdisk alone) produces minimal savings, the free space inside the VHD still contains residual data from deleted files. The VHDX container sees non-zero blocks and cannot reclaim them.
Solution: Use Method 2 (zero-fill + compact) for maximum savings. The zero-fill step converts all free space to zeros, making those blocks eligible for compaction.
Problem: VHD Grows During Zero-Fill
The zero-fill process (sdelete -z) temporarily writes zeros to ALL free space, which may cause the VHDX file to grow to near its maximum capacity during the operation. Ensure the host drive has enough free space to accommodate this temporary growth.
Solution: Verify that the host drive has at least as much free space as the VHD's maximum size minus its current size. After zero-fill and compaction, the file will be smaller than before.
Best Practices and Rules
1. Always Attach as Read-Only for Compaction
attach vdisk readonly prevents any writes during the compaction process, protecting against corruption from background processes.
2. Measure Before and After
Always record the file size before and after compaction. This tracks the effectiveness of your compaction strategy and provides evidence for storage capacity planning.
3. Schedule Regular Compaction
Dynamic VHDs accumulate waste gradually. Schedule monthly compaction (Method 4) during maintenance windows to prevent waste from growing unchecked.
4. Use Deep Compaction Periodically
Basic compaction (Method 1) is fast but may miss blocks with residual data. Perform deep compaction (Method 2) quarterly or when basic compaction produces minimal savings.
5. Stop VMs Before Compacting
Running VMs lock their VHD files. Plan compaction during maintenance windows when VMs can be shut down. For VMs that cannot be stopped, consider using Hyper-V checkpoints to compact the parent VHD while a checkpoint handles active I/O.
6. Log Every Compaction
Record the file, before/after sizes, and savings percentage. Over time, this data reveals which VMs accumulate the most waste and may benefit from larger compaction intervals or different storage configurations.
Conclusions
Compacting VHD files is critical maintenance for virtual environments. Dynamic VHDs grow but never automatically shrink, silently consuming host storage. By using read-only DiskPart compaction for regular maintenance, deep zero-fill compaction for maximum recovery, and batch processing for multi-VHD environments, you reclaim significant storage on your host servers. Measuring and logging the savings ensures the effort is justified and helps identify VMs that need more frequent attention.