The Hidden Delta Disk Nobody Cared About

Table of contents
- "Delta Disks? Who Cares?"
- From Background Noise to Audit Trigger
- What It Does
- Want to Fix This Together?
- Script Header – Protecting Myself, Pushing It All Away
- From Here On: We Break It Down, One Section at a Time
- Section 2: Connecting to vCenter – The “Please Let This Work” Moment
- That Was the Setup — Now for the Real Fun
- Section 3: Refreshing Storage Info – The “You Checked the Box, Now Live With It” Part
- Batched, For Your Cluster’s Safety
- Bonus: Let Things Cool Down
- Why This Even Needs Refreshing in the First Place
- But Do You Really Need It?
- Section 4: Collecting Delta Disk Details – Finally, the Part We Care About
- Why This Matters
- How the Delta Disk Size Is Calculated
- That’s It — The Magic Formula
- If You’re Still Reading This…
- Why Estimating AppVolumes Usage Is an Even Bigger Hack
- The Workaround: Template Size of Attached Packages Only
- AppVolumes Estimation — The Hack Continues
- Export Time: Or It Didn’t Happen
- The Wrap-Up: Summary, Sanity, and Spilled Coffee
- And Now… the Full Script (for Copy-Pasta Convenience)
- And Now, On a Serious Note to finish off the post.
- Thanks for Reading

The Hidden Delta Disk Nobody Cared About
Until Your Horizon Migration Made It a Licensed, Metered, Possibly Expensive Nightmare
By Mark Platte – accidental storage whistleblower
Disclaimer – Read Before You Rage
This post is extensive, occasionally opinionated, and definitely not 100% accurate.
It’s not a whitepaper. It’s not official guidance. It’s not even fair to every vendor involved.
What it is:
• A hopefully entertaining deep dive into an overlooked newly introduced Horizon "licensing" problem
• A script-backed attempt to shine light on fuzzy storage math
• A blunt reminder that “non-persistent” doesn’t mean “non-impactful”
If you're looking for strictly documented truth, call your TAM.
If you're looking for real-world insight, battle scars, and the occasional sarcastic eye-roll — you're in the right place.
and I attached the full script with extensive explanation, so if you do find this useful or think my reasoning isn’t completely off the rails, you’ll walk away with something practical too.
So let's start with a cute fairy tale :
Once upon a time in your Horizon environment, delta disks were... fine.
Harmless. Invisible. Disposable.
Especially in non-persistent VDI, where you assume every desktop wipes itself clean.
Nothing to see here. Right?
And then came your big Horizon migration.
New version. New hosts. New license. New rules.
Suddenly: storage is licensed.
Specifically: 100 GB raw capacity per licensed CPU core under VVF for VDI.
And just like that, the ephemeral, ignored delta disk might become the most expensive thing you never tracked.
"Delta Disks? Who Cares?"
You should. Now.
For years, nobody cared about delta disks in non-persistent environments:
They're temporary
They vanish after logoff
You never had to measure them
But now?
You have to keep your max delta disk usage within your licensed raw storage per core.
Even if the disk only exists for a few hours.
Even if it gets deleted every night.
Because licensing doesn’t care it only cares for RAW storage in your cluster !
From Background Noise to Audit Trigger
Before the license model changed, you planned for:
CPU cores per desktop
RAM per user
vGPU allocation
RAID + FTT configs
Storage? "Just give me enough."
That was the plan.
Now?
Your licensing model has a hard ceiling:
100 GB raw capacity per core, and that includes every short-lived delta disk that ever bloomed out of nowhere.
So your average 12 GB delta disk?
With FTT=2 → 24 GB raw
With RAID-X → add X% overhead based on the RAID level
4–5 desktops like that and... you just maxed out a license core
And if you’re not measuring your peak delta,
you’re flying blind — right into non-compliance.
Why I Made the Script
PowerCLI? Shrugs.
vCenter? Politely misleads.
Consultants? Evaporate faster than a writable volume.
The Big 2 ........ still waiting !
Nobody — and I mean nobody — could tell me:
How big the delta disks actually were
When they peaked
What part of “committed” was base disk vs. delta
How to track storage in a non-persistent setup before the VMs vanished
But maybe you can — after reading this post.
If any of it resonates, makes sense, or sparks a better idea, drop it in the comments.
I’d honestly love to hear how others are dealing with this — or if I’ve completely missed the mark.
So I made a script.
To help me catch delta disk usage at peak, before it disappears — and before licensing notices.
What It Does
GUI-based login (low friction)
Optional VM storage refresh (⚠ only for test or vCenter stressing use with caution)
Per-VM:
Committed size
Base disk vs. delta disk (calculated)
Extra files and leftovers
AppVolumes: provisioned apps (template size only (because that's all PowerCLI knows))
Output:
Full CSV breakdown
Summary report (average, top 50%, total)
AppVolumes attached
Real Numbers, Real Risk
Let’s break it down with the kind of numbers that quietly ruin your license compliance:
Delta Disk Size (typical): 10–15 GB per VM
Sounds small — until you multiply it by every non-persistent desktop spinning up in parallel.
FTT=2 Raw Overhead: 20–30 GB raw per VM
Because vSAN stores 2 full copies. You’re not storing 10 GB — you’re storing 20.
The Result:
Just 3–5 VMs with modest delta disks? That’s one core license gone.
So yes — your smart GPU consolidation just backfired on the storage ledger.
AppVolumes: Counted by template size, not actual usage.
Sure, compression and deduplication could help, but those aren’t factored in here.
Too unpredictable. Too risky to rely on.
Horizon VVF License Limit: 100 GB raw per licensed CPU core
This isn’t about what’s powered on, or what’s “active.”
It’s about total raw "vSAN storage in your cluster" — provisioned, replicated, existing. and without caring of it's own overhead.
Critical Insight:
You need to keep your total raw usage across the entire cluster under your licensed limit —
including every ephemeral delta, every AppVolume, every redundancy copy — or get ready to explain why your "stateless" VMs are costing you very real storage licenses.
\I'm not even going into Writable Volumes or Persistent desktops in this post — we went non-persistent for the WIN.
But just imagine what this storage licensing madness would look like in those* scenarios.
It’s a whole other level of "oops, we need more cores."
Disclaimers (Because Someone Will Ask)
Omnissa / Broadcom does not publish a method to directly retrieve delta disk usage in vSAN with Horizon — at least not that I’m aware of.
Delta disk size is inferred by subtracting base from committed values retrieved from vCenter. That’s it. No secret API, no vSAN wizardry. Just math.
AppVolumes sizing only shows the allocated/template size, not actual disk usage. I can only fetch what’s visible from attached VMDK files per VM — which excludes on-demand packages and historical versions.vSAN RAID/FTT overhead depends on your specific policy and setup — FTT=1, FTT=2, RAID-5, RAID-6… choose your own multiplier.
This script is a real-world workaround, not an official tool. Use it for visibility, not audit-proof compliance.
It might be possible to pull more accurate raw usage data using vSAN health modules or CLI tools directly on your VMware appliances (like RVC or vSAN mgmt APIs), but I intentionally stayed away from that to avoid hitting production systems in unsafe ways.
Key Lesson: Delta ≠ Disposable When It’s Licensed
In a non-persistent setup, delta disks feel irrelevant.
They’re wiped on logoff, gone by morning, nobody tracks them — and why would they?
But your license model is based on every GB in your cluster and this should cover all your use-cases.
That means you need to:
Track peak delta usage, not just snapshots in time
Estimate raw consumption including RAID and FTT overhead
Design for the worst case, not what “usually” happens
And above all, stay within your total licensed raw capacity
Want to Fix This Together?
Got a better way to capture delta peaks in volatile VMs?
Know how to get real AppVolumes usage?
Reverse-engineered how vSAN actually stores deltas?
Email me: mimenlcode@gmail.com
Or drop a comment, fork, rant, or shared pain story — I'll try to read them all
Script Header – Protecting Myself, Pushing It All Away
Before we dive into anything that touches your precious vCenter, let me be crystal clear — this part exists entirely to protect me and push all liability as far away from me as possible.
Disclaimer
# Copyright (c) 2025 Mark Platte
# Contact: mimenlcode@gmail.com
# This script is free to use under MIT-style terms with attribution.
# Blog use, redistribution, and modifications are allowed with proper credit.
# This script is provided "as is" without warranty of any kind.
# Use at your own risk. The author accepts no liability for any damage or data loss.
Translation:
You’re free to use, modify, republish, or tape this to the side of your SAN — just give credit.
But if something catches fire, deletes itself, bricks your vCenter, or wipes your CEO’s VDI desktop,
that’s on you.
From Here On: We Break It Down, One Section at a Time
From here on, we’ll go through the script section by section — because just dumping 400+ lines of PowerCLI and telling you “good luck” isn’t helpful (or nice).
Each part will be explained, mocked (where deserved), and hopefully make sense by the end.
The fully compiled script is included at the bottom of the post, so if you’re the “scroll to the bottom and copy/paste” type — I got you too.
Now let’s start with the first functional block…
Section 1: GUI Login – Because Typing Credentials into the Script Is a Bad Idea
Let’s start with something basic: a GUI prompt to enter your vCenter credentials.
Because hardcoding your username and password into a script? No thanks.
And no, we’re not pulling from a credential vault here — just good old-fashioned click-and-type.
GUISetup
#region GUI: Get Credentials
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$form = New-Object System.Windows.Forms.Form
$form.Text = "Connect to vCenter"
$form.Size = New-Object System.Drawing.Size(350,250)
$form.StartPosition = "CenterScreen"
$label1 = New-Object System.Windows.Forms.Label
$label1.Text = "vCenter Server:"
$label1.Location = New-Object System.Drawing.Point(10,20)
$label1.Size = New-Object System.Drawing.Size(100,20)
$form.Controls.Add($label1)
$textBoxServer = New-Object System.Windows.Forms.TextBox
$textBoxServer.Location = New-Object System.Drawing.Point(120,20)
$textBoxServer.Size = New-Object System.Drawing.Size(200,20)
$form.Controls.Add($textBoxServer)
$label2 = New-Object System.Windows.Forms.Label
$label2.Text = "Username:"
$label2.Location = New-Object System.Drawing.Point(10,60)
$label2.Size = New-Object System.Drawing.Size(100,20)
$form.Controls.Add($label2)
$textBoxUser = New-Object System.Windows.Forms.TextBox
$textBoxUser.Location = New-Object System.Drawing.Point(120,60)
$textBoxUser.Size = New-Object System.Drawing.Size(200,20)
$form.Controls.Add($textBoxUser)
$label3 = New-Object System.Windows.Forms.Label
$label3.Text = "Password:"
$label3.Location = New-Object System.Drawing.Point(10,100)
$label3.Size = New-Object System.Drawing.Size(100,20)
$form.Controls.Add($label3)
$textBoxPass = New-Object System.Windows.Forms.TextBox
$textBoxPass.Location = New-Object System.Drawing.Point(120,100)
$textBoxPass.Size = New-Object System.Drawing.Size(200,20)
$textBoxPass.UseSystemPasswordChar = $true
$form.Controls.Add($textBoxPass)
$checkBoxRefresh = New-Object System.Windows.Forms.CheckBox
$checkBoxRefresh.Text = "Refresh storage info vCenter (takes a while)"
$checkBoxRefresh.Location = New-Object System.Drawing.Point(120,130)
$checkBoxRefresh.Size = New-Object System.Drawing.Size(200,30)
$form.Controls.Add($checkBoxRefresh)
$buttonOK = New-Object System.Windows.Forms.Button
$buttonOK.Text = "Connect"
$buttonOK.Location = New-Object System.Drawing.Point(120,160)
$buttonOK.DialogResult = [System.Windows.Forms.DialogResult]::OK
$form.Controls.Add($buttonOK)
$form.AcceptButton = $buttonOK
if ($form.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) {
Write-Host "Cancelled by user." -ForegroundColor Yellow
exit
}
$server = $textBoxServer.Text
$user = $textBoxUser.Text
$pass = $textBoxPass.Text
$doRefresh = $checkBoxRefresh.Checked
#endregion
import .net form assembly
#region GUI: Get Credentials
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
These two lines let PowerShell pretend it’s a 90s WinForms app.
Yes, it’s ugly. Yes, it works. (needs .net installed on your machine !)
Then we build a small form that asks for the essentials:
You get boxes for:
vCenter server address
Username
Password
And a checkbox to decide whether or not to do a full storage refresh (⚠️ more on that danger later)
We then wait for the user to hit “Connect” or run away. If you cancel? The script exits as you might expect from canceling something.
Storing the stuff needed to connect
$server = $textBoxServer.Text
$user = $textBoxUser.Text
$pass = $textBoxPass.Text
$doRefresh = $checkBoxRefresh.Checked
The script is about to talk to your vCenter and possibly trigger tasks across hundreds of VMs.
It’s better to do that after giving it the right credentials, instead of watching it fail halfway with an error about “The underlying connection was closed.”
After this we print the Banner :
My fancy art ......
# Print script banner
Write-Host ""
Write-Host "╔════════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ VM STORAGE SCANNER v1.0 ║" -ForegroundColor Yellow
Write-Host "║ Delta Disks & AppVolumes Insight Tool ║" -ForegroundColor Green
Write-Host "║ 2025 Mark Platte | mimenlcode@gmail.com ║" -ForegroundColor Red
Write-Host "║ Use at own Risk ║" -ForegroundColor Blue
Write-Host "╚════════════════════════════════════════════════════════════════════╝" -ForegroundColor White
Write-Host ""
Start-Sleep -Seconds 3
Section 2: Connecting to vCenter – The “Please Let This Work” Moment
After clicking "Connect" in the GUI, this part kicks in.
It tries to log into vCenter using the credentials you just typed —
and yes, it does it the classic way, using Connect-VIServer
.
To connect or not connect that is the question.
try {
Connect-VIServer -Server $server -User $user -Password $pass -Force -ErrorAction Stop
} catch {
Write-Host " Failed to connect to $server $_" -ForegroundColor Red
exit
}
Why the try/catch
?
Because if your credentials are wrong, the FQDN is off, or your account got locked out yesterday and nobody told you —
this will fail fast and throw a red error with a sad face.
If it does work, it moves on to grabbing all the VMs it can see:
Getting the objects to work with.
$vms = Get-VM
$totalVMs = $vms.Count
Write-Host "Found $totalVMs VM's in POD" -ForegroundColor Cyan
No filtering here — just a raw grab of every VM vCenter will show you.
We count them and print the total, so you have some idea of what you're about to unleash this script on.
Note: If your environment is huge, this part might take a moment. A good moment for a cup of coffee !
That Was the Setup — Now for the Real Fun
Alright, that was all the boring-but-necessary stuff:
• A GUI to grab credentials
• Connecting to vCenter
• Counting up the VMs so we know what we’re dealing with
Now we’re diving into the real fun — the part where things can stress vCenter, upset vSAN, and actually give us useful insights (and maybe regrets).
Let’s start with the optional-but-dangerous:
Refreshing storage info on every single VM.
Yes, you checked that box.
Yes, you were warned.
Let’s go.
Section 3: Refreshing Storage Info – The “You Checked the Box, Now Live With It” Part
So… you ticked the box that says
“Refresh storage info vCenter (takes a while)”
and now the script is doing exactly that — refreshing storage layout details for every VM it can find.
If you're in a small environment: you’ll barely notice.
If you're in a large one: you might want to go get coffee. Or dinner.
The first magical part
#region Refresh Storage Info
if ($doRefresh) {
Write-Host "Refreshing storage info for all VMs..." -ForegroundColor Yellow
$batchSize = 25
$pauseBetweenBatches = 10 # seconds
$totalVMs = $vms.Count
$globalCounter = 0
$vmChunks = $vms | ForEach-Object -Begin { $i = 0 } -Process {
[PSCustomObject]@{ Index = $i++; VM = $_ }
} | Group-Object { [math]::Floor($_.Index / $batchSize) }
foreach ($chunk in $vmChunks) {
foreach ($item in $chunk.Group) {
$vm = $item.VM
$globalCounter++
try {
Write-Host " [$globalCounter/$totalVMs] Refreshing VM: $($vm.Name)" -ForegroundColor Cyan
$vmView = Get-View -Id $vm.Id -Property Storage, LayoutEx
foreach ($usage in $vmView.Storage.PerDatastoreUsage) {
$vmView.RefreshStorageInfo()
}
Write-Host " Refreshed: $($vm.Name)" -ForegroundColor Green
} catch {
Write-Warning " Failed to refresh: $($vm.Name) - $_"
}
}
Write-Host " Waiting $pauseBetweenBatches seconds before next batch..." -ForegroundColor Gray
Start-Sleep -Seconds $pauseBetweenBatches
}
Write-Host " Waiting 30 seconds to let things calm down -ForegroundColor Gray"
for ($i = 30; $i -ge 1; $i--) {
Write-Host " Waiting... $i seconds remaining" -ForegroundColor Yellow
Start-Sleep -Seconds 1
}
Write-Host " Done waiting." -ForegroundColor Green
}
#endregion
It's optional
if ($doRefresh) {
Write-Host "Refreshing storage info for all VMs..." -ForegroundColor Yellow
This entire block only runs if you checked the checkbox earlier. And what it does is call:
Fresh info please..
$vmView.RefreshStorageInfo()
Which tells vCenter:
"Hey… could you take a fresh look at the storage layout of this VM? And do that a few hundred times?"
Yeah. Not light work.
Why this even exists:
Because sometimes vCenter lies.
It caches layout info, and if the VM’s disk layout changed recently — it won’t show up unless you explicitly refresh it.
Delta disks, stale snapshots, strange leftovers — they all hide until you force vCenter to look again.
Batched, For Your Cluster’s Safety
To avoid nuking vCenter all at once, this block runs in batches of 25 VMs, with a 10-second pause between each batch:
Batching
$batchSize = 25
$pauseBetweenBatches = 10
This keeps things semi-reasonable on clusters with hundreds of VMs, where calling RefreshStorageInfo()
in a single loop would otherwise melt your inventory service. You can always change the batch and pause numbers to your liking
Bonus: Let Things Cool Down
Once all VMs are refreshed, the script waits an extra 30 seconds just to let vCenter breathe.
Tranquility
Write-Host " Waiting 30 seconds to let things calm down -ForegroundColor Gray"
for ($i = 30; $i -ge 1; $i--) {
Write-Host " Waiting... $i seconds remaining" -ForegroundColor Yellow
Start-Sleep -Seconds 1
}
You asked for fresh storage data.
This part makes sure you get it — but it’s not without cost.
If you’re thinking of running this on a production cluster during business hours?
Don’t.
And while we’re at it:
Also don’t run it while your cluster is busy provisioning a few hundred desktops.
vCenter has enough to do. Adding a storage refresh storm on top of a boot storm is a recipe for sadness — or worse, timeouts that look like bugs.
Why This Even Needs Refreshing in the First Place
You’d think vCenter would just keep storage info up to date, right?
Yeah… no.
In practice, vCenter updates VM storage layout info whenever it feels like it, which means:
When you manually open the VM settings
During vMotion, snapshot cleanup, or
Just eventually, in a way that feels more philosophical than scheduled
Technically, it can update within a few minutes, but in real-world troubleshooting, those minutes feel like days .
But Do You Really Need It?
In a running environment that’s been up for half a day or more?
Chances are vCenter has already updated most of the storage data — or at least enough to give you a solid feel for delta disk usage.
That’s why this option is off by default, and clearly marked as optional.
The refresh feature is mainly there for:
Lab environments
Dev/Test (DT) environments
Freshly provisioned pools
Or when you’re being paranoid (and honestly, that’s fair)
So if you’re running this script in a production pod just to get a general overview?
You can skip the refresh and save your vCenter from a workout it didn’t ask for.
But if you're validating changes or running a stress test on a test cluster or just want to try — then yeah, flip the switch and watch it crawl.
Want to go a step further?
I’ve got a minimal version of the script that will just keep querying a single VM, so you can:
Download a bunch of data inside the guest
Trigger snapshot/delta growth
And watch the delta disk size change live (well… as live as PowerCLI allows)
Perfect for controlled tests — or just proving to your team that delta disks really do grow fast if nobody’s watching.
Let me know if you'd like the mini-script too. I might post it at a later time if there is demand.
Section 4: Collecting Delta Disk Details – Finally, the Part We Care About
Now we get to the part that actually matters — the whole reason this script exists:
Finding out how much of your VM’s disk usage is being eaten by delta disks, and making sense of the committed space.
The. "Delta Disk Routine" — makes me think of dd
for a second…
But instead of cloning raw disks, this one just quietly tells you how much your non-persistent VMs are sneakily using
#region Collect VM Storage Details
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm"
$baseName = "$timestamp" + "_" + ($server.Replace('.', '_'))
$results = @()
$index = 0
Write-Host "Start Collecting Delta information" -ForegroundColor Yellow
start-sleep -Seconds 3
foreach ($vm in $vms) {
try {
$index++
Write-Host " Processing VM $index of $totalVMs : $($vm.Name)" -ForegroundColor Cyan
$vmView = Get-View -Id $vm.Id -Property Storage, LayoutEx
foreach ($usage in $vmView.Storage.PerDatastoreUsage) {
$ds = Get-Datastore -Id $usage.Datastore
$committedGB = [math]::Round($usage.Committed / 1GB, 2)
$totalBase = 0; $totalDelta = 0; $totalOther = 0
$baseList = @(); $deltaList = @(); $extraList = @()
foreach ($file in $vmView.LayoutEx.File) {
$fileName = $file.Name
$sizeGB = [math]::Round($file.Size / 1GB, 2)
if ($fileName -like "*-flat.vmdk" -or ($fileName -like "*.vmdk" -and $fileName -notlike "*-0000*.vmdk")) {
$totalBase += $file.Size
$baseList += "$fileName ($sizeGB GB)"
} elseif ($fileName -like "*-0000*.vmdk") {
$totalDelta += $file.Size
$deltaList += "$fileName ($sizeGB GB)"
} else {
$totalOther += $file.Size
$extraList += "$fileName ($sizeGB GB)"
}
}
$baseGB = [math]::Round($totalBase / 1GB, 2)
$otherGB = [math]::Round($totalOther / 1GB, 2)
$deltaCalcGB = [math]::Round($committedGB - $baseGB, 2)
Write-Host " → $($ds.Name):" -ForegroundColor Yellow
Write-Host " • Committed : $committedGB GB" -ForegroundColor Gray
Write-Host " • BaseDisk : $baseGB GB" -ForegroundColor Gray
Write-Host " • ExtraFiles (info) : $otherGB GB" -ForegroundColor Gray
Write-Host (" • DeltaDisk : {0} GB (Committed - Base = {1} - {2})" -f $deltaCalcGB, $committedGB, $baseGB) -ForegroundColor Gray
$results += [PSCustomObject]@{
VMName = $vm.Name
Datastore = $ds.Name
CommittedGB = $committedGB
BaseDiskGB = $baseGB
ExtraFilesTotalGB = $otherGB
DeltaDiskGB = $deltaCalcGB
BaseFiles = $baseList -join "; "
DeltaFiles = $deltaList -join "; "
ExtraFiles = $extraList -join "; "
}
}
} catch {
Write-Warning " Failed on VM $($vm.Name): $_"
}
}
#endregion
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm"
$baseName = "$timestamp" + "_" + ($server.Replace('.', '_'))
This sets up a clean filename structure for later exports. You’ll thank me when you’re looking through old CSVs wondering which cluster they came from.
Looping Through Every VM
We start iterating through every VM, calling Get-View to dig into its storage and layout details — because the standard Get-VM doesn’t give us what we need here.
The loop
foreach ($vm in $vms) {
try {
$index++
Write-Host " Processing VM $index of $totalVMs : $($vm.Name)" -ForegroundColor Cyan
$vmView = Get-View -Id $vm.Id -Property Storage, LayoutEx
This gives us direct access to the VM's actual layout and storage usage.
We then look at each VM’s storage per datastore and collect:
Committed size: what vCenter thinks the VM is using
BaseDisk size: calculated based on flat
.vmdk
filesDeltaDisk size: calculated as
Committed - Base
Extras: anything weird — think snapshots, leftover descriptor files, etc.
Another loop
foreach ($usage in $vmView.Storage.PerDatastoreUsage) {
$ds = Get-Datastore -Id $usage.Datastore
$committedGB = [math]::Round($usage.Committed / 1GB, 2)
$totalBase = 0; $totalDelta = 0; $totalOther = 0
$baseList = @(); $deltaList = @(); $extraList = @()
foreach ($file in $vmView.LayoutEx.File) {
$fileName = $file.Name
$sizeGB = [math]::Round($file.Size / 1GB, 2)
if ($fileName -like "*-flat.vmdk" -or ($fileName -like "*.vmdk" -and $fileName -notlike "*-0000*.vmdk")) {
$totalBase += $file.Size
$baseList += "$fileName ($sizeGB GB)"
} elseif ($fileName -like "*-0000*.vmdk") {
$totalDelta += $file.Size
$deltaList += "$fileName ($sizeGB GB)"
} else {
$totalOther += $file.Size
$extraList += "$fileName ($sizeGB GB)"
}
}
This loop starts the per-datastore breakdown for each VM.
foreach ($usage in $vmView.Storage.PerDatastoreUsage) {
$ds = Get-Datastore -Id $usage.Datastore
$committedGB = [math]::Round($usage.Committed / 1GB, 2)
This loop runs once per datastore the VM is using (yes, VMs can span multiple).
We get the total committed storage for that VM on that datastore — aka what vCenter believes is being used right now.
$ds = Get-Datastore -Id $usage.Datastore
This just resolves the friendly name of the datastore for reporting.
Later, you’ll want to know whether that delta disk is sitting on vsanDatastore, SSD-Performance-Tier, or some poor forgotten NFS share.
Now We Try to Sort That Mess
$totalBase = 0; $totalDelta = 0; $totalOther = 0
$baseList = @(); $deltaList = @(); $extraList = @()
We’re going to manually categorize every file in the VM's layout:
Base disks
Delta disks
And everything else we don’t quite trust
Then we go through every file in the VM:
foreach ($file in $vmView.LayoutEx.File) {
$fileName = $file.Name
$sizeGB = [math]::Round($file.Size / 1GB, 2)
We grab the name and size of each file. Then we use some naming convention trickery to figure out what kind of file it is.
if ($fileName -like "*-flat.vmdk" -or ($fileName -like "*.vmdk" -and $fileName -notlike "*-0000*.vmdk")) {
# Base disk}
if it ends in
-flat.vmdk
or is a.vmdk
without a-0000
suffix, we treat it as a base diskThe base disk is the original disk the VM boots from (parent of all snapshots)
elseif ($fileName -like "*-0000*.vmdk") {
# Delta disk}
If it contains -0000
, we assume it's a delta disk (aka snapshot disk, child of the base)
else {
# Other junk
}
Everything else? We dump it into the “extra” pile. This might include:
.vmsn
snapshot state files.vmem
memory dumps.log
filesOld leftovers that didn’t get cleaned up
We keep track of all sizes per category and store filename + size strings in separate lists so we can include them in the final CSV.
Why This Matters
VMware doesn’t give you a per-file breakdown in the GUI.
This logic reconstructs what’s going on at the storage level using best-effort logic, so you can tell:
What’s legit storage (base)
What’s probably untracked growth (delta)
What’s just clutter (extra)
And yes — it’s all based on filename patterns.
It’s not perfect.
But it’s a hell of a lot better than “vCenter says you’re using 24.5 GB, good luck figuring out why.”
How the Delta Disk Size Is Calculated
Once we’ve looped through all the files and figured out what’s base, what’s delta, and what’s just noise, we get to the key calculation:
$deltaCalcGB = [math]::Round($committedGB - $baseGB, 2)
That’s It — The Magic Formula
Delta = Committed – Base
We don’t try to sum up all the *-0000*.vmdk
files directly, because:
They might not be listed cleanly or consistently — especially in complex snapshot chains or if cleanup ran halfway
And even worse: retrieving their size often gives you
0
, especially on vSAN, where files are object-based, not file-based, and don’t behave like classic flat storage
(Thanks, vSAN. Super helpful.)
So instead, we rely on what vCenter does know:
The committed size — which includes everything — base, delta, leftovers, and any mess you forgot to delete.
So we subtract the known base size, and what’s left is assumed to be delta growth.
Is it 100% precise?
No.
But it’s accurate enough to catch spikes, see usage patterns.
If You’re Still Reading This…
You’ve earned a strong cup of coffee.
Because what’s next makes delta disk estimation feel like a polished VMware feature.
Welcome to AppVolumes — where storage tracking is so vague it borders on performance art.
Why Estimating AppVolumes Usage Is an Even Bigger Hack
First off:
vCenter via PowerCLI returns 0
for AppVolumes VMDK sizes.
Every. Single. Time.
So if you thought delta disks were elusive — AppVolumes isn’t even pretending to show up.
So we turn to the AppVolumes Manager API for help…
…and that’s where things get even messier.
It doesn’t include all datastore information — especially if your packages are replicated across pods
It only returns the file size of the internal package contents
And when it does return something, it’s just the raw size of the package — not what’s consumed on vSAN
The Workaround: Template Size of Attached Packages Only
So here’s what the script does:
We estimate storage usage by looking at the template size of packages that are currently attached to VMs.
That means:
No on-demand AppStacks
No outdated versions
No extra replicas
Just the templates tied to live sessions
It’s a deliberate undercount, but it’s the only semi-consistent number you can get — and it’s at least tied to real, active use.
Also important:
This only applies when VMDK-based packages are stored on vSAN, which is the most common case in direct attach scenarios.
If you’re using network storage… this script is cool but you don't need the AppVolumes section since your AppVolumes consumed storage is not backed by vSAN.
And you don't need to account for it in your cluster.
Applacadabra!,Summon the invisible. Count the unknowable. And try not to scream.
#region Collect AppVolumes
Write-Host "Start collecting attached appvolumes" -ForegroundColor Yellow
Start-Sleep -Seconds 5
$diskList = @()
$index = 0
foreach ($vm in $vms) {
$index++
Write-Host " Processing VM $index of $totalVMs : $($vm.Name)" -ForegroundColor Cyan
try {
$disks = Get-HardDisk -VM $vm -ErrorAction Stop | Where-Object {
$_.Filename -match "appvolumes"
} | ForEach-Object {
[PSCustomObject]@{
VMName = $vm.Name
Datastore = $_.Filename -replace "^\[(.*?)\].*$", '$1'
SizeGB = [math]::Round($_.CapacityKB / 1MB, 2)
Path = $_.Filename
}
}
Write-Host " → Found $($disks.Count) AppVolume disk(s) for VM $($vm.Name)" -ForegroundColor Gray
$diskList += $disks
} catch {
Write-Warning " Failed to query hard disks for VM $($vm.Name): $_"
}
}
$uniqueDisks = $diskList | Sort-Object Path -Unique
$totalAppVolSize = ($uniqueDisks | Measure-Object -Property SizeGB -Sum).Sum
$totalAppVolCount = $uniqueDisks.Count
#endregion
AppVolumes Estimation — The Hack Continues
This section is where we try — try — to extract AppVolumes usage from the chaos.
Since vCenter returns 0
for the actual VMDK sizes of AppVolumes (because vSAN does its weird object magic), we go low-level and just look at what VMs have mounted.
Here’s what we do:
$disks = Get-HardDisk -VM $vm -ErrorAction Stop | Where-Object {
$_.Filename -match "appvolumes"}
We fetch all hard disks attached to the VM, and filter by filename containing "appvolumes"
.
Not elegant. Not documented. But it catches real, attached VMDKs with the AppVolumes signature.
Note: This only sees attached disks.
So on-demand AppStacks or outdated versions sitting quietly in storage?
Not counted.
Then we clean up the data into something readable:
[PSCustomObject]@{
VMName = $vm.Name
Datastore = $_.Filename -replace "^\[(.*?)\].*$", '$1'
SizeGB = [math]::Round($_.CapacityKB / 1MB, 2)
Path = $_.Filename}
VMName: Self-explanatory
Datastore: Extracted from the
[datastore] path.vmdk
formatSizeGB: Rounded from KB because PowerCLI returns everything like it’s 2003
Path: Full path, just so we can sort and deduplicate later
We log it, then collect all disks from all VMs, and deduplicate them:
$uniqueDisks = $diskList | Sort-Object Path -Unique
Because let’s be honest — if 20 VMs mount the same AppVolume, it should only count once for storage purposes.
Total Cost (Storage-wise)
Finally, we total up:
$totalAppVolSize = ($uniqueDisks | Measure-Object -Property SizeGB -Sum).Sum
$totalAppVolCount = $uniqueDisks.Count
Boom — you now have:
How many unique AppVolume disks were actually mounted
Their combined size as seen from the VM’s side, not vSAN’s internal metrics
Still not perfect. Still missing datastores in multi-site replication setups. (Just run script on other pods or use one POD to rule them all (it's replicated right)
But again — better than zero.
Export Time: Or It Didn’t Happen
Because if you can’t open it in Excel and slap some red highlights on it — did it even count?
This bit takes everything we collected and exports it straight to the folder where you ran the script from.
Nice, clean, timestamped filenames so you know which run it came from:
Full VM storage breakdown →
Full_storage_report.csv
AppVolumes mount info →
AppVolumes_report.csv
Perfect for sharing, panicking, or proving that yes — your delta disks really did just eat 300 GB in 12 minutes.
This should be very self explanitory
#region Export Results
$csvPath = Join-Path $PSScriptRoot "$baseName`_Full_storage_report.csv"
$appvolPath = Join-Path $PSScriptRoot "$baseName`_AppVolumes_report.csv"
$summaryPath = Join-Path $PSScriptRoot "$baseName`_Storage_Summary.txt"
$results | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
$uniqueDisks | Export-Csv -Path $appvolPath -NoTypeInformation -Encoding UTF8
Write-Host "`n Exported full storage report to: $csvPath" -ForegroundColor Green
Write-Host " Exported AppVolumes report to: $appvolPath" -ForegroundColor Green
#endregion
The Wrap-Up: Summary, Sanity, and Spilled Coffee
So what did all this actually get you?
Well a lot of script and still a big part to come for sure.
The final calculations.... almost there just a couple of bytes to go.
#region Generate Summary and Finalize
# Summary calculations
Write-Host "Starting summary generation please wait" -ForegroundColor Yellow
$deltaValues = $results | Select-Object -ExpandProperty DeltaDiskGB
$avgAll = [math]::Round(($deltaValues | Measure-Object -Average).Average, 2)
$topHalf = $deltaValues | Sort-Object -Descending | Select-Object -First ([math]::Floor($deltaValues.Count / 2))
$avgTopHalf = [math]::Round(($topHalf | Measure-Object -Average).Average, 2)
$totalDeltaSum = [math]::Round(($deltaValues | Measure-Object -Sum).Sum, 2)
$totalDeltaTopHalfSum = [math]::Round(($topHalf | Measure-Object -Sum).Sum, 2)
$totalCombinedStorage = [math]::Round($totalDeltaSum + $totalAppVolSize, 2)
$summaryPath = Join-Path $PSScriptRoot "$baseName`_Storage_Summary.txt"
$summaryText = @"
╔════════════════════════════════════════════════════════════════════╗
║ VM STORAGE SCANNER v1.0 ║
║ Delta Disks & AppVolumes Insight Tool ║
║ 2025 Mark Platte | mimenlcode@gmail.com ║
╚════════════════════════════════════════════════════════════════════╝
Storage Summary Report - $server
Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm")
────────────────────────────────────────────
VM Delta Disk Summary
• Total VMs processed : $($results.Count)
• Avg DeltaDisk (all VMs) : $avgAll GB
• Avg DeltaDisk (top 50% VMs) : $avgTopHalf GB
• Total DeltaDisk (all VMs) : $totalDeltaSum GB
• Total DeltaDisk (top 50%) : $totalDeltaTopHalfSum GB
App Volumes Summary
• Attached AppVolumes VMDKs : $totalAppVolCount
• Total Size of AppVolumes : $totalAppVolSize GB
Combined Total
• Total Delta + AppVolumes : $totalCombinedStorage GB
Output Files (in script directory)
• $(Split-Path -Leaf $csvPath)
→ Per-VM storage usage: committed, base, extra, delta disk breakdown
• $(Split-Path -Leaf $appvolPath)
→ List of unique AppVolume VMDKs currently attached to VMs
• $(Split-Path -Leaf $summaryPath)
→ This summary report: key metrics and interpretation notes
Important note on vSAN FTT and Raw Capacity Estimation:
This script reports logical disk usage (e.g., delta size) as seen by vCenter, which includes vSAN replication overhead depending on the storage policy.
If you're planning capacity for a new environment or migrating data, you should convert these values back to raw storage per copy before applying the new RAID/FTT setting.
Example:
An 18 GB delta disk reported here on a datastore using FTT=2 (i.e., two full copies) means only 9 GB of raw storage is actually needed for one copy.
Use this 9 GB as your baseline when estimating raw storage for your new policy.
Always recalculate based on your vSAN storage policy (FTT=1/2, RAID-5, RAID-6) to get accurate raw capacity needs.
Important Note on AppVolumes Size Accuracy:
The reported AppVolumes VMDK sizes reflect the template/provisioned size, not actual disk usage.
This is because PowerCLI reports these disks with a size of 0 GB for allocated space, and does not expose real usage data.
As a result, sizing should be treated as an upper-bound estimate, especially when planning storage needs.
This report includes only AppVolumes VMDK files currently attached to VMs.
To estimate total AppVolume storage:
• Multiply by 3 if you retain 3 versions per app
• Include unattached (on-demand) volumes not visible in this scan
Tip1:
Use the CSV files for filtering, sorting, and further analysis in Excel, Power BI, or scripts.
Tip 2:
Run this script at different times across multiple days to get a better understanding of storage trends and variations in delta disk growth.
This script provides an estimation based on the current state as seen by vCenter, not an absolute truth. Use the data accordingly and interpret results in context.
Disclaimer:
This script is based on practical testing to assess whether reported usage matches vCenter’s view of VM storage.
There is no official or publicly available documentation detailing how to accurately retrieve delta disk usage from vSAN in a Horizon context.As such, the data should be interpreted as an informed approximation — not a definitive or guaranteed source.
────────────────────────────────────────────
"@
$summaryText | Out-File -FilePath $summaryPath -Encoding UTF8
Write-Host "`n Exported summary to: $summaryPath" -ForegroundColor Green
# Open the summary file
if (Test-Path $summaryPath) {
Start-Process -FilePath $summaryPath
} else {
Write-Warning "Could not open summary: file not found at $summaryPath"
}
# Disconnect and wait
Disconnect-VIServer -Server $server -Confirm:$false
Write-Host "`nPress Enter to exit..."
[void][System.Console]::ReadLine()
#endregion
So what did all this actually get you?
This final section crunches everything into one nice readable report, right next to your script — so you don’t have to dig through CSVs like it’s 2009.
We calculate:
Average delta size (for all VMs and the top half — because outliers matter)
Total delta storage
AppVolumes storage (only what was mounted)
Combined total usage (so you can panic properly)
And we print it all in a clean summary file like this:
📦 Storage Summary Report - your-vcenter.local
🕒 Generated: 2025-07-29 11:02
▶ VM Delta Disk Summary
• Total VMs processed : 241
• Avg DeltaDisk (all VMs) : 12.3 GB
• Avg DeltaDisk (top 50% VMs) : 17.8 GB
• Total DeltaDisk (all VMs) : 2960 GB
• Total DeltaDisk (top 50%) : 2153 GB
...
We even toss in some warnings, usage caveats, and a note that:
This isn’t official, it’s just the best we’ve got unless the vSan and Horizon gods start helping.”
And yes — the script auto-opens the summary file for you when it’s done. No hunting, no dragging, no swearing at Notepad.
Then we disconnect politely from vCenter (because good manners), and leave you staring at your actual problem in text form.
And Now… the Full Script (for Copy-Pasta Convenience)
If you made it this far — you're clearly either deeply committed or a little unwell (same).
So here’s the deal:
Save this script as a
.ps1
file before running it
(⚠️ Logs and CSVs are saved in the same folder — so if you just paste it into ISE without saving first, you're gonna wonder where your files went)Then run it from PowerShell ISE with PowerCLI loaded
And watch the truth about your delta disks crawl into daylight
You’ll get:
• vCenter login GUI
• Optional full storage refresh (slow but satisfying)
• Delta disk + AppVolumes breakdown
• CSVs and a readable TXT summary
• Auto-opened summary and a clean disconnect
# Copyright (c) 2025 Mark Platte
# Contact: mimenlcode@gmail.com
# This script is free to use under MIT-style terms with attribution.
# Blog use, redistribution, and modifications are allowed with proper credit.
# This script is provided "as is" without warranty of any kind.
# Use at your own risk. The author accepts no liability for any damage or data loss.
#region GUI: Get Credentials
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$form = New-Object System.Windows.Forms.Form
$form.Text = "Connect to vCenter"
$form.Size = New-Object System.Drawing.Size(350,250)
$form.StartPosition = "CenterScreen"
$label1 = New-Object System.Windows.Forms.Label
$label1.Text = "vCenter Server:"
$label1.Location = New-Object System.Drawing.Point(10,20)
$label1.Size = New-Object System.Drawing.Size(100,20)
$form.Controls.Add($label1)
$textBoxServer = New-Object System.Windows.Forms.TextBox
$textBoxServer.Location = New-Object System.Drawing.Point(120,20)
$textBoxServer.Size = New-Object System.Drawing.Size(200,20)
$form.Controls.Add($textBoxServer)
$label2 = New-Object System.Windows.Forms.Label
$label2.Text = "Username:"
$label2.Location = New-Object System.Drawing.Point(10,60)
$label2.Size = New-Object System.Drawing.Size(100,20)
$form.Controls.Add($label2)
$textBoxUser = New-Object System.Windows.Forms.TextBox
$textBoxUser.Location = New-Object System.Drawing.Point(120,60)
$textBoxUser.Size = New-Object System.Drawing.Size(200,20)
$form.Controls.Add($textBoxUser)
$label3 = New-Object System.Windows.Forms.Label
$label3.Text = "Password:"
$label3.Location = New-Object System.Drawing.Point(10,100)
$label3.Size = New-Object System.Drawing.Size(100,20)
$form.Controls.Add($label3)
$textBoxPass = New-Object System.Windows.Forms.TextBox
$textBoxPass.Location = New-Object System.Drawing.Point(120,100)
$textBoxPass.Size = New-Object System.Drawing.Size(200,20)
$textBoxPass.UseSystemPasswordChar = $true
$form.Controls.Add($textBoxPass)
$checkBoxRefresh = New-Object System.Windows.Forms.CheckBox
$checkBoxRefresh.Text = "Refresh storage info vCenter (takes a while)"
$checkBoxRefresh.Location = New-Object System.Drawing.Point(120,130)
$checkBoxRefresh.Size = New-Object System.Drawing.Size(200,30)
$form.Controls.Add($checkBoxRefresh)
$buttonOK = New-Object System.Windows.Forms.Button
$buttonOK.Text = "Connect"
$buttonOK.Location = New-Object System.Drawing.Point(120,160)
$buttonOK.DialogResult = [System.Windows.Forms.DialogResult]::OK
$form.Controls.Add($buttonOK)
$form.AcceptButton = $buttonOK
if ($form.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) {
Write-Host "Cancelled by user." -ForegroundColor Yellow
exit
}
$server = $textBoxServer.Text
$user = $textBoxUser.Text
$pass = $textBoxPass.Text
$doRefresh = $checkBoxRefresh.Checked
#endregion
# Print script banner
Write-Host ""
Write-Host "╔════════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ VM STORAGE SCANNER v1.0 ║" -ForegroundColor Yellow
Write-Host "║ Delta Disks & AppVolumes Insight Tool ║" -ForegroundColor Green
Write-Host "║ 2025 Mark Platte | mimenlcode@gmail.com ║" -ForegroundColor Red
Write-Host "║ Use at own Risk ║" -ForegroundColor Blue
Write-Host "╚════════════════════════════════════════════════════════════════════╝" -ForegroundColor White
Write-Host ""
Start-Sleep -Seconds 3
#region Connect to vCenter
try {
Connect-VIServer -Server $server -User $user -Password $pass -Force -ErrorAction Stop
} catch {
Write-Host " Failed to connect to $server $_" -ForegroundColor Red
exit
}
#endregion
$vms = Get-VM
$totalVMs = $vms.Count
Write-Host "Found $totalVMs VM's in POD" -ForegroundColor Cyan
#region Refresh Storage Info
if ($doRefresh) {
Write-Host "Refreshing storage info for all VMs..." -ForegroundColor Yellow
$batchSize = 25
$pauseBetweenBatches = 10 # seconds
$totalVMs = $vms.Count
$globalCounter = 0
$vmChunks = $vms | ForEach-Object -Begin { $i = 0 } -Process {
[PSCustomObject]@{ Index = $i++; VM = $_ }
} | Group-Object { [math]::Floor($_.Index / $batchSize) }
foreach ($chunk in $vmChunks) {
foreach ($item in $chunk.Group) {
$vm = $item.VM
$globalCounter++
try {
Write-Host " [$globalCounter/$totalVMs] Refreshing VM: $($vm.Name)" -ForegroundColor Cyan
$vmView = Get-View -Id $vm.Id -Property Storage, LayoutEx
foreach ($usage in $vmView.Storage.PerDatastoreUsage) {
$vmView.RefreshStorageInfo()
}
Write-Host " Refreshed: $($vm.Name)" -ForegroundColor Green
} catch {
Write-Warning " Failed to refresh: $($vm.Name) - $_"
}
}
Write-Host " Waiting $pauseBetweenBatches seconds before next batch..." -ForegroundColor Gray
Start-Sleep -Seconds $pauseBetweenBatches
}
Write-Host " Waiting 30 seconds to let things calm down -ForegroundColor Gray"
for ($i = 30; $i -ge 1; $i--) {
Write-Host " Waiting... $i seconds remaining" -ForegroundColor Yellow
Start-Sleep -Seconds 1
}
Write-Host " Done waiting." -ForegroundColor Green
}
#endregion
#region Collect VM Storage Details
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm"
$baseName = "$timestamp" + "_" + ($server.Replace('.', '_'))
$results = @()
$index = 0
Write-Host "Start Collecting Delta information" -ForegroundColor Yellow
start-sleep -Seconds 3
foreach ($vm in $vms) {
try {
$index++
Write-Host " Processing VM $index of $totalVMs : $($vm.Name)" -ForegroundColor Cyan
$vmView = Get-View -Id $vm.Id -Property Storage, LayoutEx
foreach ($usage in $vmView.Storage.PerDatastoreUsage) {
$ds = Get-Datastore -Id $usage.Datastore
$committedGB = [math]::Round($usage.Committed / 1GB, 2)
$totalBase = 0; $totalDelta = 0; $totalOther = 0
$baseList = @(); $deltaList = @(); $extraList = @()
foreach ($file in $vmView.LayoutEx.File) {
$fileName = $file.Name
$sizeGB = [math]::Round($file.Size / 1GB, 2)
if ($fileName -like "*-flat.vmdk" -or ($fileName -like "*.vmdk" -and $fileName -notlike "*-0000*.vmdk")) {
$totalBase += $file.Size
$baseList += "$fileName ($sizeGB GB)"
} elseif ($fileName -like "*-0000*.vmdk") {
$totalDelta += $file.Size
$deltaList += "$fileName ($sizeGB GB)"
} else {
$totalOther += $file.Size
$extraList += "$fileName ($sizeGB GB)"
}
}
$baseGB = [math]::Round($totalBase / 1GB, 2)
$otherGB = [math]::Round($totalOther / 1GB, 2)
$deltaCalcGB = [math]::Round($committedGB - $baseGB, 2)
Write-Host " → $($ds.Name):" -ForegroundColor Yellow
Write-Host " • Committed : $committedGB GB" -ForegroundColor Gray
Write-Host " • BaseDisk : $baseGB GB" -ForegroundColor Gray
Write-Host " • ExtraFiles (info) : $otherGB GB" -ForegroundColor Gray
Write-Host (" • DeltaDisk : {0} GB (Committed - Base = {1} - {2})" -f $deltaCalcGB, $committedGB, $baseGB) -ForegroundColor Gray
$results += [PSCustomObject]@{
VMName = $vm.Name
Datastore = $ds.Name
CommittedGB = $committedGB
BaseDiskGB = $baseGB
ExtraFilesTotalGB = $otherGB
DeltaDiskGB = $deltaCalcGB
BaseFiles = $baseList -join "; "
DeltaFiles = $deltaList -join "; "
ExtraFiles = $extraList -join "; "
}
}
} catch {
Write-Warning " Failed on VM $($vm.Name): $_"
}
}
#endregion
#region Collect AppVolumes
Write-Host "Start collecting attached appvolumes" -ForegroundColor Yellow
Start-Sleep -Seconds 5
$diskList = @()
$index = 0
foreach ($vm in $vms) {
$index++
Write-Host " Processing VM $index of $totalVMs : $($vm.Name)" -ForegroundColor Cyan
try {
$disks = Get-HardDisk -VM $vm -ErrorAction Stop | Where-Object {
$_.Filename -match "appvolumes"
} | ForEach-Object {
[PSCustomObject]@{
VMName = $vm.Name
Datastore = $_.Filename -replace "^\[(.*?)\].*$", '$1'
SizeGB = [math]::Round($_.CapacityKB / 1MB, 2)
Path = $_.Filename
}
}
Write-Host " → Found $($disks.Count) AppVolume disk(s) for VM $($vm.Name)" -ForegroundColor Gray
$diskList += $disks
} catch {
Write-Warning " Failed to query hard disks for VM $($vm.Name): $_"
}
}
$uniqueDisks = $diskList | Sort-Object Path -Unique
$totalAppVolSize = ($uniqueDisks | Measure-Object -Property SizeGB -Sum).Sum
$totalAppVolCount = $uniqueDisks.Count
#endregion
#region Export Results
$csvPath = Join-Path $PSScriptRoot "$baseName`_Full_storage_report.csv"
$appvolPath = Join-Path $PSScriptRoot "$baseName`_AppVolumes_report.csv"
$summaryPath = Join-Path $PSScriptRoot "$baseName`_Storage_Summary.txt"
$results | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
$uniqueDisks | Export-Csv -Path $appvolPath -NoTypeInformation -Encoding UTF8
Write-Host "`n Exported full storage report to: $csvPath" -ForegroundColor Green
Write-Host " Exported AppVolumes report to: $appvolPath" -ForegroundColor Green
#endregion
#region Generate Summary and Finalize
# Summary calculations
Write-Host "Starting summary generation please wait" -ForegroundColor Yellow
$deltaValues = $results | Select-Object -ExpandProperty DeltaDiskGB
$avgAll = [math]::Round(($deltaValues | Measure-Object -Average).Average, 2)
$topHalf = $deltaValues | Sort-Object -Descending | Select-Object -First ([math]::Floor($deltaValues.Count / 2))
$avgTopHalf = [math]::Round(($topHalf | Measure-Object -Average).Average, 2)
$totalDeltaSum = [math]::Round(($deltaValues | Measure-Object -Sum).Sum, 2)
$totalDeltaTopHalfSum = [math]::Round(($topHalf | Measure-Object -Sum).Sum, 2)
$totalCombinedStorage = [math]::Round($totalDeltaSum + $totalAppVolSize, 2)
$summaryPath = Join-Path $PSScriptRoot "$baseName`_Storage_Summary.txt"
$summaryText = @"
╔════════════════════════════════════════════════════════════════════╗
║ VM STORAGE SCANNER v1.0 ║
║ Delta Disks & AppVolumes Insight Tool ║
║ 2025 Mark Platte | mimenlcode@gmail.com ║
╚════════════════════════════════════════════════════════════════════╝
Storage Summary Report - $server
Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm")
────────────────────────────────────────────
VM Delta Disk Summary
• Total VMs processed : $($results.Count)
• Avg DeltaDisk (all VMs) : $avgAll GB
• Avg DeltaDisk (top 50% VMs) : $avgTopHalf GB
• Total DeltaDisk (all VMs) : $totalDeltaSum GB
• Total DeltaDisk (top 50%) : $totalDeltaTopHalfSum GB
App Volumes Summary
• Attached AppVolumes VMDKs : $totalAppVolCount
• Total Size of AppVolumes : $totalAppVolSize GB
Combined Total
• Total Delta + AppVolumes : $totalCombinedStorage GB
Output Files (in script directory)
• $(Split-Path -Leaf $csvPath)
→ Per-VM storage usage: committed, base, extra, delta disk breakdown
• $(Split-Path -Leaf $appvolPath)
→ List of unique AppVolume VMDKs currently attached to VMs
• $(Split-Path -Leaf $summaryPath)
→ This summary report: key metrics and interpretation notes
Important note on vSAN FTT and Raw Capacity Estimation:
This script reports logical disk usage (e.g., delta size) as seen by vCenter, which includes vSAN replication overhead depending on the storage policy.
If you're planning capacity for a new environment or migrating data, you should convert these values back to raw storage per copy before applying the new RAID/FTT setting.
Example:
An 18 GB delta disk reported here on a datastore using FTT=2 (i.e., two full copies) means only 9 GB of raw storage is actually needed for one copy.
Use this 9 GB as your baseline when estimating raw storage for your new policy.
Always recalculate based on your vSAN storage policy (FTT=1/2, RAID-5, RAID-6) to get accurate raw capacity needs.
Important Note on AppVolumes Size Accuracy:
The reported AppVolumes VMDK sizes reflect the template/provisioned size, not actual disk usage.
This is because PowerCLI reports these disks with a size of 0 GB for allocated space, and does not expose real usage data.
As a result, sizing should be treated as an upper-bound estimate, especially when planning storage needs.
This report includes only AppVolumes VMDK files currently attached to VMs.
To estimate total AppVolume storage:
• Multiply by 3 if you retain 3 versions per app
• Include unattached (on-demand) volumes not visible in this scan
Tip1:
Use the CSV files for filtering, sorting, and further analysis in Excel, Power BI, or scripts.
Tip 2:
Run this script at different times across multiple days to get a better understanding of storage trends and variations in delta disk growth.
This script provides an estimation based on the current state as seen by vCenter, not an absolute truth. Use the data accordingly and interpret results in context.
Disclaimer:
This script is based on practical testing to assess whether reported usage matches vCenter’s view of VM storage.
There is no official or publicly available documentation detailing how to accurately retrieve delta disk usage from vSAN in a Horizon context.
As such, the data should be interpreted as an informed approximation — not a definitive or guaranteed source.
────────────────────────────────────────────
"@
$summaryText | Out-File -FilePath $summaryPath -Encoding UTF8
Write-Host "`n Exported summary to: $summaryPath" -ForegroundColor Green
# Open the summary file
if (Test-Path $summaryPath) {
Start-Process -FilePath $summaryPath
} else {
Write-Warning "Could not open summary: file not found at $summaryPath"
}
# Disconnect and wait
Disconnect-VIServer -Server $server -Confirm:$false
Write-Host "`nPress Enter to exit..."
[void][System.Console]::ReadLine()
#endregion
And Now, On a Serious Note to finish off the post.
“When Storage Becomes the Bottleneck — and the Constraint”
Behind all the sarcasm lies a serious architectural shift that many Horizon administrators are now confronting head-on.
In the legacy VMware licensing model, storage wasn’t something you had to worry about — at least not from a licensing perspective. But with the Broadcom acquisition and transition to the Omnissa model, that changed. The VVF for VDI license (vSphere + vSAN for Horizon) now caps raw storage at 100 GiB per licensed CPU core, with no supported expansion path, as clearly stated in Omnissa documentation.
Quote
“There is no longer an option to purchase VVF for VDI with a vSAN capacity SKU.”
— Omnissa KB 6000381Quote
Starting with vSphere / vSAN 8.0U2b, there are no longer separate license keys for vSphere and vSAN. A combo key now governs both. If you exceed the 100 GiB/core vSAN limit, vCenter will alert you and switch the cluster to vSAN trial mode. At that point, you are expected to either reduce assigned capacity or add more CPU cores. There is no supported method to license additional storage capacity for Horizon environments under this model.
Notably, if your storage usage stays below the limit, vCenter will still label the vSAN license as a “trial license” with an expiration date in 2075 — but this is considered a valid production license, per the same KB article.
Why This Matters
This introduces significant complexity into storage planning. To maintain operational headroom and remain within license boundaries, you now need to balance physical disk capacity against compute cores. That’s nontrivial. A 64-core host, for example, should not exceed 6.4 TiB of raw capacity — meaning two 3.2 TiB disks max. Given that 3 TiB+ drives have become the standard, this constraint forces a pivot to smaller drives (e.g., 1.6 TiB) just to preserve flexibility in larger-core hosts (e.g., 96-core nodes).
And the cost pressure doesn’t end there. In some cases, supporting persistent writable volumes may require scaling up CPU core counts purely to raise the storage ceiling — driving up hardware costs without a corresponding need for compute. That’s a fundamentally inefficient trade-off that undermines virtualization’s appeal, especially for persistent desktops.
What’s worse is how invisible this problem can be during the licensing transition. In many environments, actual storage usage is inflated by legacy bloat: old AppVolumes, large base images, copy sprawl, or unnecessary downloads triggered by automation or auto-updates. Until you face the VVF for VDI model, that excess goes unnoticed — but when you migrate, it turns into a scaling crisis. Suddenly, you may need far more hosts than expected just to justify under optimized storage consumption.
Where It Hurts Most
This tension hits hardest when building smaller Horizon pods with high application counts or AppVolumes demand, where capacity needs grow but scale-out options shrink. In theory, non-persistent environments without writables should be manageable — but in practice, writable volumes can become impossible to support within the VVF for VDI limits.
A (Very) Modest Optimism
At the time of writing, VCF 9 introduced deduplication alongside compression for vSAN ESA. This could help reduce pressure in volatile environments — but since deduplication is post-process, its actual benefit in highly dynamic non-persistent scenarios remains uncertain.
Final Thoughts
With this script, I hope to provide others facing the same migration a clearer view of actual storage usage. Even if your architecture looks sound on paper, the raw numbers often tell a different story.
But more than that, I hope this post sparks discussion. Because this licensing change risks crippling persistent use cases entirely — whether it’s full desktops, Linux, developer environments, or writable volume-driven personalization. Shifting to in-guest AppVolumes mode instead of direct-attach breaks compatibility for some legacy applications due to strict application load order requirements. That’s not just inconvenient — it’s a feature regression.
Storage has now become a first-class citizen in the Horizon sizing discussion.
It’s no longer just about vGPU ratios, CPU overcommit, or RAM. The amount of AppVolumes storage and delta space required per user might ultimately define your cluster size, fault domain, and number of pods — or worse, force consolidation into fewer, larger clusters, increasing blast radius by necessity, not by choice.
Thanks for Reading
If you made it this far — respect, again.
Whether you scrolled, skimmed, or actually ran the script (you brave soul), I hope you walked away with something useful — or at least slightly less confused than before.
I did get support from several vendors and partners who genuinely tried to help tackle the delta disk visibility issue.
Unfortunately, no one was able to provide a consistent, working method — and certainly no one dared to sign off on this script’s results. Still, a big thanks to everyone who contributed thoughts, testing time, or ideas along the way. You know who you are.
Oh, and I’ve added the script as a .txt
file to the downloads in this post.
Bonus: There’s also a small text file containing all the ASCII symbols used in the script (the added copy the code blocks just deleted the icons) — for the creative souls out there.
You’ve earned it.
Drop me a comment, send me an email, or PM me if something’s bugging you, if you’ve got questions, or if you just want to say hi.
I’ll try to reply when I can.
Thanks again for sticking around —
Mark
Full script as text : https://drive.google.com/file/d/1EBu-tyA1egjbwbw6pfM4jSM7QGVRBR9L/view?usp=sharing
Bonus icons : https://drive.google.com/file/d/1gI0wEnQetnux6lXxoi5U1kOYlz0_-w42/view?usp=sharing
Subscribe to my newsletter
Read articles from Mark Platte directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Mark Platte
Mark Platte
Mark Platte Born in January, 1984.I currently work as an IT architect in a hospital environment, with a career shaped by hands-on experience in application development, storage engineering, and infrastructure design. My roots are in software, but over time I moved deeper into system architecture, working closely with storage platforms, virtualization, and security especially in regulated and research-intensive environments. I have a strong focus on building stable, secure, and manageable IT solutions, particularly in complex environments where clinical systems, research data, and compliance requirements intersect. I’m especially experienced in enterprise storage design, backup strategies, and performance tuning, often acting as the bridge between engineering teams and long-term architectural planning. I enjoy solving difficult problems and still believe most issues in IT can be fixed with enough determination, focus, and sometimes budget. It’s that drive to find solutions that keeps me motivated.