A PowerCLI scheduled task that balances VM memory across hosts in a vSphere cluster that is not licensed for DRS. Calculates the cluster's average host memory utilisation, finds hosts more than a configurable threshold above the average, and vMotions one VM per hot host to the coldest host in the cluster.

The original 2013 post pointed at a script on Scott Warren's blog (now offline) that implemented a hand-rolled DRS for vSphere Standard clusters. The post below replaces the dead link with a modern, parameterised version that runs on PowerCLI 13 and PowerShell 7.
What the script does
- Enumerates every host in a named cluster and calculates each host's memory utilisation as a percentage of total RAM.
- Computes the cluster-wide average and flags hosts that are more than
-ThresholdPercentabove that average (default 10 percentage points). - For each hot host, picks the powered-on VM with the smallest allocated memory: the cheapest one to move.
- vMotions that VM to the coldest host in the cluster (the one with the most free RAM), capped at
-MaxMovesPerRunmigrations per execution. - Emits a structured
[PSCustomObject]report of every action taken or skipped, so it can be piped toExport-Csvfrom a scheduled task.
PowerShell
#requires -Version 7.0
#requires -Modules VMware.VimAutomation.Core
<#
.SYNOPSIS
Poor-man's DRS for vSphere Standard / Essentials Plus clusters.
.DESCRIPTION
Calculates per-host memory utilisation in a named cluster, finds hosts
that exceed the cluster average by ThresholdPercent, and vMotions the
lowest-memory powered-on VM from each hot host to the coldest host.
Supports -WhatIf for dry runs. Caps work per execution with
-MaxMovesPerRun.
.PARAMETER vCenter
vCenter Server FQDN to connect to.
.PARAMETER Cluster
Cluster name. Only this cluster's hosts and VMs are evaluated.
.PARAMETER ThresholdPercent
Percentage points above the cluster average at which a host is
considered "hot". Default 10.
.PARAMETER MaxMovesPerRun
Maximum number of vMotions to issue per invocation. Default 3.
.PARAMETER LogPath
Optional path. If supplied, the structured report is appended as CSV.
.EXAMPLE
.\Invoke-PoorMansDrs.ps1 -vCenter vc01.contoso.local -Cluster 'Prod-Standard' -WhatIf
.EXAMPLE
.\Invoke-PoorMansDrs.ps1 -vCenter vc01.contoso.local -Cluster 'Prod-Standard' -MaxMovesPerRun 2 -LogPath 'C:\Logs\poor-mans-drs.csv'
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory)] [string] $vCenter,
[Parameter(Mandatory)] [string] $Cluster,
[ValidateRange(1, 90)] [int] $ThresholdPercent = 10,
[ValidateRange(1, 50)] [int] $MaxMovesPerRun = 3,
[string] $LogPath
)
$ErrorActionPreference = 'Stop'
Import-Module VMware.VimAutomation.Core
Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false -Scope Session | Out-Null
# Connect using the credential of the user running the script.
# For unattended scheduled tasks see "How to use it" for SSPI / stored creds.
$server = Connect-VIServer -Server $vCenter
try {
$clusterObj = Get-Cluster -Name $Cluster -Server $server
if (-not $clusterObj) { throw "Cluster '$Cluster' not found on $vCenter." }
# 1. Collect per-host memory utilisation.
$hosts = Get-VMHost -Location $clusterObj -Server $server |
Where-Object { $_.ConnectionState -eq 'Connected' -and $_.PowerState -eq 'PoweredOn' } |
ForEach-Object {
$totalGB = [math]::Round($_.MemoryTotalGB, 2)
$usedGB = [math]::Round($_.MemoryUsageGB, 2)
[pscustomobject]@{
Host = $_
Name = $_.Name
TotalGB = $totalGB
UsedGB = $usedGB
FreeGB = [math]::Round($totalGB - $usedGB, 2)
PercentUsed = if ($totalGB -gt 0) { [math]::Round(($usedGB / $totalGB) * 100, 2) } else { 0 }
}
}
if ($hosts.Count -lt 2) {
Write-Warning "Cluster '$Cluster' has fewer than two connected hosts. Nothing to balance."
return
}
# 2. Cluster average and report.
$avg = [math]::Round(($hosts | Measure-Object PercentUsed -Average).Average, 2)
Write-Host ("Cluster '{0}' average memory utilisation: {1}%" -f $Cluster, $avg) -ForegroundColor Cyan
$hosts | Sort-Object PercentUsed -Descending | Format-Table Name, TotalGB, UsedGB, PercentUsed -AutoSize | Out-String | Write-Host
# 3. Identify hot hosts and the coldest target.
$hotHosts = $hosts | Where-Object { $_.PercentUsed -gt ($avg + $ThresholdPercent) } |
Sort-Object PercentUsed -Descending
$coldestHost = $hosts | Sort-Object PercentUsed | Select-Object -First 1
if (-not $hotHosts) {
Write-Host "No hosts exceed average + $ThresholdPercent%. Nothing to do." -ForegroundColor Green
return
}
# 4. Plan and execute moves.
$report = New-Object System.Collections.Generic.List[object]
$moves = 0
foreach ($hot in $hotHosts) {
if ($moves -ge $MaxMovesPerRun) {
Write-Host "Reached MaxMovesPerRun ($MaxMovesPerRun). Stopping." -ForegroundColor Yellow
break
}
# Skip if the coldest host is the hot host itself or is already saturated.
if ($coldestHost.Name -eq $hot.Name) { continue }
if ($coldestHost.PercentUsed -ge ($avg + $ThresholdPercent)) {
Write-Warning "No suitable cold host: cluster is uniformly hot."
break
}
# Pick the powered-on VM on the hot host with the smallest MemoryGB.
$candidate = Get-VM -Location $hot.Host -Server $server |
Where-Object { $_.PowerState -eq 'PoweredOn' } |
Sort-Object MemoryGB |
Select-Object -First 1
if (-not $candidate) {
Write-Warning "No powered-on VMs to move from $($hot.Name)."
continue
}
$entry = [pscustomobject]@{
Timestamp = (Get-Date).ToString('s')
Cluster = $Cluster
VM = $candidate.Name
VMMemoryGB = [math]::Round($candidate.MemoryGB, 2)
SourceHost = $hot.Name
SourcePctUsed = $hot.PercentUsed
TargetHost = $coldestHost.Name
TargetPctUsed = $coldestHost.PercentUsed
ClusterAvgPct = $avg
Action = ''
Error = ''
}
if ($PSCmdlet.ShouldProcess("$($candidate.Name) ($($hot.Name) -> $($coldestHost.Name))", 'Move-VM')) {
try {
Move-VM -VM $candidate -Destination $coldestHost.Host -VMotionPriority High -Confirm:$false | Out-Null
$entry.Action = 'Moved'
$moves++
Write-Host ("Moved {0} from {1} to {2}" -f $candidate.Name, $hot.Name, $coldestHost.Name) -ForegroundColor Green
}
catch {
$entry.Action = 'Failed'
$entry.Error = $_.Exception.Message
Write-Warning ("Move failed: {0}" -f $_.Exception.Message)
}
}
else {
$entry.Action = 'WhatIf'
}
$report.Add($entry)
}
# 5. Output structured report.
$report | Format-Table Timestamp, VM, VMMemoryGB, SourceHost, TargetHost, Action -AutoSize | Out-String | Write-Host
if ($LogPath) {
$report | Export-Csv -Path $LogPath -NoTypeInformation -Append
Write-Host "Report appended to $LogPath" -ForegroundColor Cyan
}
$report
}
finally {
Disconnect-VIServer -Server $server -Confirm:$false -ErrorAction SilentlyContinue
}
How to use it
Dry run first:
.\Invoke-PoorMansDrs.ps1 `
-vCenter vc01.contoso.local `
-Cluster 'Prod-Standard' `
-ThresholdPercent 10 `
-MaxMovesPerRun 3 `
-WhatIf
Once you are happy with the planned moves, remove -WhatIf. To schedule it nightly on a Windows jump box with PowerShell 7 installed:
# Run from an elevated PowerShell 7 prompt on the jump box.
$action = New-ScheduledTaskAction `
-Execute 'C:\Program Files\PowerShell\7\pwsh.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Invoke-PoorMansDrs.ps1" -vCenter vc01.contoso.local -Cluster Prod-Standard -MaxMovesPerRun 3 -LogPath C:\Logs\poor-mans-drs.csv'
$trigger = New-ScheduledTaskTrigger -Daily -At 02:30
$principal = New-ScheduledTaskPrincipal `
-UserId 'CONTOSO\svc-vmwaresched' `
-LogonType Password `
-RunLevel Highest
Register-ScheduledTask `
-TaskName 'Poor Mans DRS - Prod-Standard' `
-Action $action -Trigger $trigger -Principal $principal `
-Description 'Memory rebalancing for vSphere Standard cluster.'
For unattended runs you have three sensible options for credentials, in order of preference:
- Run the task as a service account that already has the vSphere role required to vMotion (and let SSPI handle auth via
Connect-VIServer -Server $vCenterwithout a-Credential). - Store an encrypted credential with
New-VICredentialStoreItemand load it inside the script beforeConnect-VIServer. - Use a workload identity / vCenter service-principal token if you have vCenter 8.0 U2 or later with the identity broker enabled.
Do not put a plaintext password into the scheduled task arguments.
Notes and modern alternatives
- Use DRS if you have it. vSphere Enterprise Plus has DRS built in and does this far better than a script can: it accounts for CPU as well as memory, considers active vs consumed memory, respects affinity rules, and runs continuously. This script exists because Standard and Essentials Plus do not include DRS.
- Memory utilisation only. The script does not consider CPU, IOPS, or network. If you are CPU-bound, this will not help you; add a similar pass on
CpuUsageMhz / CpuTotalMhzif you need it. - Active vs consumed memory.
MemoryUsageGBreports consumed memory, which on ESXi includes overhead and may overstate pressure on hosts using transparent page sharing. The hypervisor's own "active memory" counter viaGet-Statis a better signal for serious tuning. The script usesMemoryUsageGBfor simplicity and parity with the original. - Smallest-VM heuristic. Moving the smallest VM means cheapest vMotions and least risk, but it may also mean repeated runs are needed to bring a very hot host down. Increase
-MaxMovesPerRunor schedule more often if you need faster convergence. - PowerCLI 13 is cross-platform. Because PowerCLI 13 runs on PowerShell 7, you can also schedule this on a Linux box with
systemdtimers if you do not have a Windows jump box.
Security notes
- Scope vCenter permissions. The account used to run the script needs
Resource.Migrate powered-on virtual machineandResource.Assign virtual machine to resource poolon the cluster. Do not give it admin. - Cap the blast radius.
-MaxMovesPerRun 3prevents a misconfigured threshold from chain-moving every VM in the cluster on the first night. - Always run with
-WhatIffirst on a new cluster. The script prints the full plan before doing anything. - Centralise logging. Use
-LogPathso every move is recorded. Pipe the CSV into your SIEM if vMotion activity is a tracked event. - Roll back. vMotion is non-disruptive and reversible; you can always migrate a VM back. There is no destructive operation in the script (
Move-VMonly relocates).
Original 2013 script
Kept for historical reference, but do not run it: the Add-PSSnapin VMware.VimAutomation.Core line will not load on modern PowerCLI / PowerShell 7, and the syntax errors visible in the legacy version (concatenated lines, the balanced2.csv typo) were artefacts of how the post was originally copied from Scott Warren's blog.
Set-StrictMode -version 2
function GetPercentages() {
$objHosts = get-vmhost
foreach ($objHost in $objHosts) {
$objHost | add-member NoteProperty PercentMemory ($objHost.MemoryUsageMB / $objHost.MemoryTotalMB * 100)
$objHost
}
}
function GetVMToMove($strHost, $arrAllVMs) {
$objVMs = get-vm | where { ($_.Host.Name -eq $strHost) -and ($_.PowerState -eq "PoweredOn") }
if (test-path "balanced.csv") {
$arrStatic = import-csv "balanced.csv"
foreach ($objStatic in $arrStatic) {
$objVM = $objVM | where { $_.Name -ne ($objStatic.Name) }
}
}
$objVMs = $objVMs | sort "Memory*"
$objVMs[0]
}
$arrAllVMs = get-vm | where { $_.PowerState -eq "PoweredOn" }
$bMoved = $true
while ($bMoved) {
$objHosts = GetPercentages | sort PercentMemory -descending | select Name, PercentMemory
$intAverage = 0; $intCount = 0
foreach ($objHost in $objHosts) { $intAverage += $objHost.PercentMemory; $intCount++ }
$intAverage /= $intCount
echo ("Average is " + $intAverage)
$objHosts | select Name, PercentMemory
$strDest = $objHosts[$objHosts.Count - 1].Name
$bMoved = $false
foreach ($objHost in $objHosts) {
if ($objHost.PercentMemory -gt ($intAverage + 10)) {
$objVM = GetVMToMove $objHost.Name
echo ('Moving ' + $objVM.Name + " from " + $objHost.Name)
$objVM | move-vm -destination (get-vmhost $strDest)
$bMoved = $true
}
}
}




