THE PROBLEM
DHCP scope exhaustion is one of the most misdiagnosed outages in IT.
Nothing is down. The switches are fine, the firewall is fine, DNS resolves, the servers are all green. But people arriving on the 8:45 wave can't get on the network, and the ones who got in at 7:30 are working normally. So you spend forty minutes looking at the wireless controller before somebody thinks to open the DHCP console and finds the scope sitting at 100%.
It's almost always gradual. Headcount grows. Everyone carries a phone and a laptop now instead of just a laptop. Someone adds a guest VLAN that shares a scope it shouldn't. The scope crosses 80% in June, 90% in August, and nobody notices because DHCP has no alerting of its own and the console shows a number you only see when you go looking.
The fix is not complicated. It's just that nobody is watching. This week we build the watcher.
THE SCRIPT
Save the script below to:
C:\Scripts\DHCPScopeMonitor.ps1powershell
# DHCP Scope Utilization Monitor
# Automate & Operate — automateandoperate.com
Import-Module DhcpServer
# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)
$logFile = "C:\Logs\DHCPScopeMonitor.log"
$reportFile = "C:\Logs\DHCPScopeReport.csv"
$trendFile = "C:\Logs\DHCPScopeTrend.csv"
# DHCP servers to monitor
# Leave empty to auto-discover authorized servers from AD
$dhcpServers = @()
# Utilization thresholds
$warningThreshold = 80
$criticalThreshold = 90
# Flag scopes with a lease duration longer than this (in hours)
# Long leases on high-churn networks are a common cause of false exhaustion
$longLeaseHours = 24
# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
New-Item -ItemType Directory -Path "C:\Logs" -Force | Out-Null
}
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$dateStamp = Get-Date -Format "yyyy-MM-dd"
$report = @()
$critical = @()
$warning = @()
$failoverIssues = @()
$longLeases = @()
# ---------- DISCOVER DHCP SERVERS ----------
if ($dhcpServers.Count -eq 0) {
try {
$dhcpServers = (Get-DhcpServerInDC -ErrorAction Stop).DnsName
Add-Content -Path $logFile -Value "$timestamp — Auto-discovered $($dhcpServers.Count) authorized DHCP server(s)"
} catch {
Add-Content -Path $logFile -Value "$timestamp — DISCOVERY FAILED — $_ — set `$dhcpServers manually"
$dhcpServers = @("localhost")
}
}
foreach ($server in $dhcpServers) {
# ---------- SCOPE UTILIZATION ----------
try {
$scopes = Get-DhcpServerv4Scope -ComputerName $server -ErrorAction Stop
foreach ($scope in $scopes) {
try {
$stats = Get-DhcpServerv4ScopeStatistics -ComputerName $server -ScopeId $scope.ScopeId -ErrorAction Stop
$percentUsed = [math]::Round($stats.PercentageInUse, 1)
$leaseHours = [math]::Round($scope.LeaseDuration.TotalHours, 1)
$status = if ($scope.State -ne "Active") {
"INACTIVE"
} elseif ($percentUsed -ge $criticalThreshold) {
"CRITICAL"
} elseif ($percentUsed -ge $warningThreshold) {
"WARNING"
} else {
"OK"
}
$entry = [PSCustomObject]@{
Date = $dateStamp
Server = $server
ScopeId = $scope.ScopeId.ToString()
ScopeName = $scope.Name
State = $scope.State
StartRange = $scope.StartRange.ToString()
EndRange = $scope.EndRange.ToString()
TotalAddresses = $stats.AddressesFree + $stats.AddressesInUse
InUse = $stats.AddressesInUse
Free = $stats.AddressesFree
Reserved = $stats.Reserved
PercentUsed = $percentUsed
LeaseHours = $leaseHours
Status = $status
}
$report += $entry
if ($status -eq "CRITICAL") { $critical += $entry }
if ($status -eq "WARNING") { $warning += $entry }
# Long lease on a scope that's filling up is worth calling out
if ($leaseHours -gt $longLeaseHours -and $percentUsed -ge $warningThreshold) {
$longLeases += $entry
}
Add-Content -Path $logFile -Value "$timestamp — $server — $($scope.ScopeId) '$($scope.Name)' — $percentUsed% used ($($stats.AddressesInUse)/$($stats.AddressesFree + $stats.AddressesInUse)) — lease ${leaseHours}h — $status"
} catch {
Add-Content -Path $logFile -Value "$timestamp — $server — $($scope.ScopeId) — STATS ERROR: $_"
}
}
} catch {
$report += [PSCustomObject]@{
Date = $dateStamp
Server = $server
ScopeId = "UNREACHABLE"
ScopeName = "N/A"
State = "N/A"
StartRange = "N/A"
EndRange = "N/A"
TotalAddresses = 0
InUse = 0
Free = 0
Reserved = 0
PercentUsed = 0
LeaseHours = 0
Status = "ERROR"
}
Add-Content -Path $logFile -Value "$timestamp — $server — SERVER ERROR: $_"
}
# ---------- FAILOVER HEALTH ----------
try {
$failovers = Get-DhcpServerv4Failover -ComputerName $server -ErrorAction SilentlyContinue
foreach ($fo in $failovers) {
if ($fo.State -ne "Normal") {
$failoverIssues += [PSCustomObject]@{
Server = $server
Relationship = $fo.Name
PartnerServer = $fo.PartnerServer
Mode = $fo.Mode
State = $fo.State
ScopeCount = ($fo.ScopeId | Measure-Object).Count
}
Add-Content -Path $logFile -Value "$timestamp — $server — FAILOVER '$($fo.Name)' state is $($fo.State) (partner: $($fo.PartnerServer))"
}
}
} catch {
Add-Content -Path $logFile -Value "$timestamp — $server — Failover check skipped: $_"
}
}
# ---------- EXPORT CURRENT SNAPSHOT ----------
$report | Sort-Object PercentUsed -Descending | Export-Csv -Path $reportFile -NoTypeInformation
# ---------- APPEND TO TREND HISTORY ----------
$activeScopes = $report | Where-Object { $_.ScopeId -ne "UNREACHABLE" }
if ($activeScopes.Count -gt 0) {
if (Test-Path $trendFile) {
$activeScopes | Export-Csv -Path $trendFile -NoTypeInformation -Append
} else {
$activeScopes | Export-Csv -Path $trendFile -NoTypeInformation
}
}
# ---------- CALCULATE GROWTH TREND ----------
$trendLines = @()
if (Test-Path $trendFile) {
try {
$history = Import-Csv -Path $trendFile
$sevenDaysAgo = (Get-Date).AddDays(-7).ToString("yyyy-MM-dd")
foreach ($entry in ($warning + $critical)) {
$past = $history | Where-Object {
$_.Server -eq $entry.Server -and
$_.ScopeId -eq $entry.ScopeId -and
$_.Date -le $sevenDaysAgo
} | Sort-Object Date -Descending | Select-Object -First 1
if ($past) {
$growth = [math]::Round($entry.PercentUsed - [double]$past.PercentUsed, 1)
if ($growth -gt 0) {
$pointsRemaining = 100 - $entry.PercentUsed
$weeksToFull = [math]::Round($pointsRemaining / $growth, 1)
$trendLines += "$($entry.ScopeName) ($($entry.ScopeId)) — grew $growth points in 7 days — full in approx $weeksToFull week(s)"
} else {
$trendLines += "$($entry.ScopeName) ($($entry.ScopeId)) — flat or declining over 7 days"
}
}
}
} catch {
Add-Content -Path $logFile -Value "$timestamp — Trend calculation skipped: $_"
}
}
# ---------- BUILD EMAIL ----------
$emailBody = "DHCP Scope Utilization Report — $timestamp`n"
$emailBody += "Servers checked: $($dhcpServers -join ', ')`n"
$emailBody += "Scopes found: $($report.Count)`n"
$emailBody += "Warning threshold: $warningThreshold% | Critical threshold: $criticalThreshold%`n`n"
$emailBody += "CRITICAL: $($critical.Count) | WARNING: $($warning.Count) | Failover issues: $($failoverIssues.Count)`n`n"
if ($critical.Count -gt 0) {
$emailBody += "CRITICAL — ACT NOW:`n"
$emailBody += "=" * 60 + "`n"
foreach ($c in $critical) {
$emailBody += "Scope: $($c.ScopeName) ($($c.ScopeId))`n"
$emailBody += "Server: $($c.Server)`n"
$emailBody += "Used: $($c.PercentUsed)% ($($c.InUse) in use / $($c.Free) free)`n"
$emailBody += "Range: $($c.StartRange) - $($c.EndRange)`n"
$emailBody += "Lease: $($c.LeaseHours) hours`n"
$emailBody += "-" * 40 + "`n"
}
$emailBody += "`n"
}
if ($warning.Count -gt 0) {
$emailBody += "WARNING — PLAN FOR THIS:`n"
$emailBody += "=" * 60 + "`n"
foreach ($w in $warning) {
$emailBody += "$($w.Server) | $($w.ScopeName) ($($w.ScopeId)) | $($w.PercentUsed)% | $($w.Free) free | lease $($w.LeaseHours)h`n"
}
$emailBody += "`n"
}
if ($trendLines.Count -gt 0) {
$emailBody += "7-DAY GROWTH TREND:`n"
$emailBody += "=" * 60 + "`n"
$trendLines | ForEach-Object { $emailBody += "$_`n" }
$emailBody += "`n"
}
if ($longLeases.Count -gt 0) {
$emailBody += "LONG LEASES ON BUSY SCOPES — CONSIDER SHORTENING:`n"
$emailBody += "=" * 60 + "`n"
foreach ($l in $longLeases) {
$emailBody += "$($l.ScopeName) ($($l.ScopeId)) — $($l.LeaseHours) hour lease at $($l.PercentUsed)% used`n"
}
$emailBody += "`n"
}
if ($failoverIssues.Count -gt 0) {
$emailBody += "DHCP FAILOVER NOT IN NORMAL STATE:`n"
$emailBody += "=" * 60 + "`n"
foreach ($f in $failoverIssues) {
$emailBody += "Server: $($f.Server)`n"
$emailBody += "Relationship: $($f.Relationship)`n"
$emailBody += "Partner: $($f.PartnerServer)`n"
$emailBody += "Mode: $($f.Mode)`n"
$emailBody += "State: $($f.State)`n"
$emailBody += "-" * 40 + "`n"
}
$emailBody += "`n"
}
if ($critical.Count -eq 0 -and $warning.Count -eq 0 -and $failoverIssues.Count -eq 0) {
$emailBody += "All scopes below $warningThreshold% and failover healthy.`n`n"
}
$emailBody += "ALL SCOPES:`n"
$emailBody += "=" * 60 + "`n"
foreach ($r in ($report | Sort-Object PercentUsed -Descending)) {
$emailBody += "[$($r.Status)] $($r.PercentUsed)% — $($r.ScopeName) ($($r.ScopeId)) — $($r.Free) free`n"
}
$emailBody += "`nFull report: $reportFile`n"
$emailBody += "Trend history: $trendFile`n"
$emailBody += "`nTo shorten a lease duration:`n"
$emailBody += "Set-DhcpServerv4Scope -ComputerName SERVER -ScopeId X.X.X.0 -LeaseDuration 08:00:00`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"
$subject = if ($critical.Count -gt 0) {
"DHCP CRITICAL: $($critical.Count) scope(s) above $criticalThreshold% utilization"
} elseif ($failoverIssues.Count -gt 0) {
"DHCP ALERT: $($failoverIssues.Count) failover relationship(s) not in Normal state"
} elseif ($warning.Count -gt 0) {
"DHCP Warning — $($warning.Count) scope(s) above $warningThreshold%"
} else {
"DHCP Scope Report — All scopes healthy"
}
Send-MailMessage `
-From $from -To $to `
-Subject $subject `
-Body $emailBody `
-SmtpServer $smtpServer -Port $smtpPort `
-UseSsl -Credential $credentialUpdate these lines:
$from/$to/$username/$password— your email details$smtpServer— your SMTP server (e.g.smtp.office365.com)$dhcpServers— leave empty to auto-discover from AD, or list them explicitly$warningThreshold/$criticalThreshold— 80 and 90 are sensible defaults
Requirements: Run on a DHCP server, or a machine with RSAT DHCP tools installed. The account needs DHCP read rights — DHCP Users group is enough for reading, DHCP Administrators if you want it to make changes later.
To install the module on a management box:
powershell
Add-WindowsCapability -Online -Name "Rsat.DHCP.Tools~~~~0.0.1.0"THE TREND FILE IS WHY THIS BEATS THE CONSOLE
You can see current utilization in the DHCP console any time you want. What you can't see is the direction of travel.
The script appends every run to DHCPScopeTrend.csv and compares against the reading from seven days ago. For any scope already in warning or critical, it works out how many percentage points it gained that week and projects roughly how long until it's full.
"Floor 3 Wireless is at 84%" is mildly interesting. "Floor 3 Wireless grew 6 points in 7 days — full in approx 2.7 weeks" is a task with a deadline attached.
The projection is deliberately crude — straight-line extrapolation with no seasonality. It's not trying to be clever. It just converts a static number into a countdown, which is what makes people act.
The first week produces no trend data because there's no history to compare against. From day eight onward it populates.
THE LONG LEASE SECTION
This is the one that fixes problems rather than just reporting them.
A lot of "exhausted" scopes aren't actually out of addresses — they're full of leases held by devices that left the building hours ago. Default lease duration on many scopes is 8 days, which is fine for a scope full of desktops that never move, and terrible for a guest wireless VLAN where devices appear for forty minutes and vanish.
The script flags any scope that's both above the warning threshold and carrying a lease longer than 24 hours. Those are your candidates for a quick win.
Shortening the lease on a high-churn scope:
powershell
Set-DhcpServerv4Scope -ComputerName "DHCP01" -ScopeId 10.20.30.0 -LeaseDuration 08:00:00Eight hours suits a typical office day. Two to four hours suits guest wireless. Existing leases don't shorten retroactively — they'll renew at the new duration, so the effect builds over a lease cycle rather than appearing immediately.
Don't do this on a scope full of servers or printers. Short leases on static-ish infrastructure generate pointless renewal traffic and make DHCP a harder dependency than it needs to be. Reservations are the right answer there.
THE FAILOVER CHECK
If you run DHCP failover — and you should — the script reports any relationship not sitting in Normal state.
A failover relationship stuck in CommunicationInterrupted or PartnerDown isn't an outage. Everything keeps working, which is exactly the point of failover. But you're now running without redundancy, and if you don't know that, you find out when the surviving server also has a bad day.
This silently broken state can persist for months. It's the kind of thing that only surfaces during a DR test, if you run one.
SETTING UP TASK SCHEDULER
Press Windows key → type Task Scheduler → open it
Click "Create Basic Task" → name it
DHCP Scope MonitorTrigger: Daily → 9:00am
Action: Start a program
Program:
powershell.exeArguments:
-ExecutionPolicy Bypass -File "C:\Scripts\DHCPScopeMonitor.ps1"Click Next → Finish
Under Properties → General, check "Run with highest privileges" and use an account with DHCP read rights.
9:00am is deliberate. Run this at 3am and every scope looks healthy because half your users are at home with expired leases. You want the reading taken when the building is full — that's the number that matters. If you're on a hybrid schedule, pick your busiest day rather than a random Tuesday.
Daily keeps the trend data meaningful. Weekly runs give you a projection built on two data points, which isn't worth much.
TEST IT
Run it on a DHCP server as administrator:
powershell
powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\DHCPScopeMonitor.ps1"Open C:\Logs\DHCPScopeReport.csv and sort by PercentUsed descending. That top row is the scope you should be thinking about.
To check what the script sees before running the full thing:
powershell
Get-DhcpServerInDC
Get-DhcpServerv4Scope -ComputerName "DHCP01" | Format-Table ScopeId, Name, State, LeaseDuration
Get-DhcpServerv4ScopeStatistics -ComputerName "DHCP01" | Format-Table ScopeId, AddressesInUse, AddressesFree, PercentageInUseTo force a warning while testing, temporarily set $warningThreshold = 1 — every active scope will report and you'll confirm the email formatting works.
WHEN A SCOPE IS GENUINELY FULL
Shortening leases buys time. It doesn't add addresses. If a /24 is legitimately carrying 240 devices, you have three real options:
Extend the scope range if there's unused space in the subnet and nothing static is sitting in the way. Check for static assignments and reservations before you widen the range — overlapping a static server IP into a DHCP pool is a bad afternoon.
Superscope a second range onto the same VLAN. Works, but it's a band-aid and it complicates the routing story.
Re-subnet properly — split the VLAN, move to a /23. This is the right answer and the one nobody wants to do, because it's a change window and a router config.
The value of the trend report is that it gives you enough lead time to do option three on a planned Saturday rather than option one in a panic on a Monday.
WHY THIS MATTERS
DHCP is infrastructure that works perfectly until the exact moment it doesn't, and it has no native alerting whatsoever. There's no event log entry that says "you're running out of addresses" — there's just a scope quietly climbing while nobody looks at it.
The outage it causes is also uniquely annoying to diagnose because it's partial and time-dependent. Half the users are fine. The problem appears at a specific time of day and disappears by lunch as leases free up. It looks like wireless. It looks like a switch. It looks like anything except what it is.
Twelve lines of email once a day removes that entire category of incident from your life.
THIS WEEK'S ACTION
Run it against your DHCP servers today and note the top three scopes by utilization. Schedule it daily at 9am. Check back in a week — that's when the trend column populates, and that's when you'll know whether anything needs planning before the end of the quarter.
Reply to this email if you hit any issues — I read every reply.
Automate & Operate — automateandoperate.com
How 2M+ Professionals Stay Ahead on AI
AI is moving fast and most people are falling behind.
The Rundown AI keeps you ahead of the curve.
It's a free AI newsletter that keeps you up-to-date on the latest AI news, and teaches you how to apply it in just 5 minutes a day.
Plus, complete the quiz after signing up and they’ll recommend the best AI tools, guides, and courses — tailored to your needs.

