Six Hours of Copy-Paste, Gone in One Loop
Last quarter we inherited a client environment with 140 Windows servers. The previous admin had been manually checking disk space on each one. Every. Single. Morning. That is the kind of toil that makes SREs lose sleep. The fix was a 30-line PowerShell script built around three PowerShell loop structures: for, foreach, and while. Took about 20 minutes to write. Saved roughly 6 hours a week.
If you are still writing one-off commands for repetitive tasks, you are burning time you cannot get back. Loops are the single most important construct for turning manual work into automation. Here is how each one works, when to pick one over the other, and the mistakes I see in client scripts constantly.
The For Loop: When You Know the Count
The for loop is your go-to when you know exactly how many iterations you need. It follows a classic three-part structure: initialization, condition, and increment.
for ($i = 1; $i -le 5; $i++) {
Write-Host "Iteration $i"
}
This runs exactly five times. Nothing more, nothing less. The structure breaks down like this:
- Initialization:
$i = 1sets the counter before the first iteration. - Condition:
$i -le 5is evaluated before each pass. If it returns$true, the block executes. If$false, the loop exits. - Increment:
$i++runs after each iteration, bumping the counter.
PowerShell keeps executing the statement block and increment as long as the condition evaluates to $true.
Real-World For Loop: Batch Processing Servers
During a capacity planning exercise for a client running 50 VMs, we needed to pull performance counters in batches of 10 to avoid hammering their monitoring API. Here is what that looked like:
$servers = Get-Content -Path "C:\Scripts\servers.txt"
for ($i = 0; $i -lt $servers.Count; $i += 10) {
$batch = $servers[$i..([Math]::Min($i + 9, $servers.Count - 1))]
foreach ($server in $batch) {
Get-Counter -ComputerName $server -Counter "\Processor(_Total)\% Processor Time" -MaxSamples 1
}
Start-Sleep -Seconds 5
}
The for loop controls the batch window. The foreach inside handles individual servers. This pattern shows up constantly in managed environments where you need to throttle operations. If you are working with concurrent operations at scale, take a look at our walkthrough on PowerShell ThrottleLimit for concurrent network operations for more on that front.
The ForEach Loop: Processing Collections
The foreach loop is the one you will use most in PowerShell. Period. It iterates over every item in a collection and performs the same action on each one. No counter management, no off-by-one errors.
$names = @("Alice", "Bob", "Charlie")
foreach ($name in $names) {
Write-Host "Hello, $name!"
}
Each element in the array gets processed one by one. Simple.
Where foreach really shines is when you combine it with cmdlet output. PowerShell hands you arrays of objects constantly — from Get-Process to Get-ADUser to Get-ChildItem. The foreach loop is how you act on them.
ForEach in Production: Disk Space Monitoring
We spotted this pattern in a client’s event logs during a routine check — their monitoring had a blind spot for secondary drives. We wrote a quick foreach loop that later became a scheduled task. Similar to what we covered in monitoring disk space and sending alerts with PowerShell, but stripped down for their specific environment:
$servers = @("SVR-APP01", "SVR-APP02", "SVR-DB01")
foreach ($server in $servers) {
$disks = Get-WmiObject -Class Win32_LogicalDisk -ComputerName $server -Filter "DriveType=3"
foreach ($disk in $disks) {
$freePercent = [math]::Round(($disk.FreeSpace / $disk.Size) * 100, 2)
if ($freePercent -lt 15) {
Write-Warning "$server - Drive $($disk.DeviceID) is at $freePercent% free"
}
}
}
Nested foreach loops. Outer loop hits each server. Inner loop checks every drive on that server. Took 10 minutes to write, caught a nearly-full D: drive that same afternoon.
ForEach-Object: The Pipeline Variant
One caveat worth calling out: there are actually two flavors of foreach in PowerShell. The foreach statement loads the entire collection into memory first. The ForEach-Object cmdlet processes items one at a time through the pipeline:
Get-Process | Where-Object { $_.Handles -gt 500 } | ForEach-Object {
Write-Host "$($_.Name) has $($_.Handles) handles"
}
For small collections, the difference is negligible. For large datasets — say, parsing a 2 GB log file — ForEach-Object is the safer pick because it streams rather than loading everything into memory. Know the difference. It will bite you at scale.
The While Loop: Condition-Driven Iteration
The while loop keeps running as long as a condition stays true. Unlike for, you do not define iteration count upfront. It just keeps going until the condition flips.
$count = 1
while ($count -le 3) {
Write-Host "Count is $count"
$count++
}
This runs until $count exceeds 3. The condition is evaluated before each iteration. If the condition is $false from the start, the block never executes.
Do-While: Guaranteed First Run
Sometimes you need the block to execute at least once regardless of the condition. That is what do-while is for — it checks the condition after execution:
$counter = 1
do {
Write-Host "Counter is $counter"
$counter++
} while ($counter -le 3)
This guarantees at least one iteration. We use this pattern when polling services during maintenance windows — you always want to check at least once before deciding the service is healthy.
Do-Until: The Inverse
PowerShell also gives you do-until, which is the logical inverse of do-while. It keeps looping until a condition becomes true:
$i = 0
do {
Write-Host $i
$i++
} until ($i -eq 5)
Same output as the other loops, different mental model. Pick whichever reads more naturally for your use case.
While Loop in Production: Waiting for a Service
When we took over management of a client’s Azure tenant, their deployment scripts had zero retry logic. If a service was still starting after a reboot, the script would just fail silently. Here is the pattern we rolled out across three client environments:
$maxAttempts = 30
$attempt = 0
while ((Get-Service -Name "SQLServer" -ComputerName "SVR-DB01").Status -ne "Running" -and $attempt -lt $maxAttempts) {
Write-Host "Waiting for SQL Server to start... Attempt $attempt"
Start-Sleep -Seconds 10
$attempt++
}
if ($attempt -ge $maxAttempts) {
Write-Error "SQL Server did not start within 5 minutes. Check the instance."
}
Always include an escape condition with while loops. An infinite loop in a scheduled task is a bad day for everyone. The $maxAttempts counter is your safety net.
Picking the Right Loop
Here is my opinionated take after years of cleaning up client scripts:
- Use
forwhen you need index-based access or batch processing. If you are slicing arrays or stepping through items by position,foris your tool. - Use
foreachfor everything else involving collections. It is cleaner, harder to mess up, and reads better. This should be your default. - Use
whilewhen the iteration count is unknown and you are waiting for a condition to change. Polling, retries, health checks. - Use
do-whileordo-untilwhen the block must run at least once before evaluation.
I will say this plainly: if you are using for loops to iterate over arrays without needing the index, you are making your code harder to read for no reason. Use foreach. Fail fast on readability.
Combining Loops with Conditionals
Loops become genuinely powerful when you combine them with if statements and switch blocks inside. You might use an if inside a loop to skip certain items, or a switch to handle different object types.
$services = Get-Service
foreach ($svc in $services) {
switch ($svc.Status) {
'Stopped' { Write-Warning "STOPPED: $($svc.Name)" }
'Running' { Write-Host "OK: $($svc.Name)" -ForegroundColor Green }
default { Write-Host "OTHER: $($svc.Name) - $($svc.Status)" }
}
}
This is the bread and butter of sysadmin scripting. Iterate, evaluate, act. That three-step pattern underpins most of the automation we build for clients.
Break and Continue: Controlling Loop Flow
Two keywords you should know: break exits the loop entirely. continue skips the current iteration and moves to the next one.
foreach ($num in 1..10) {
if ($num -eq 5) { continue } # Skip 5
if ($num -eq 8) { break } # Stop at 8
Write-Host $num
}
Output: 1, 2, 3, 4, 6, 7. These are useful when you need to bail out early or filter without adding nested if blocks. In labeled loops, break and continue can target a specific outer loop by referencing its label — handy for nested loop structures.
Stop Writing Commands by Hand
Every repetitive task in your environment is a loop waiting to be written. Disk checks, user provisioning, GPO backups, service monitoring — all of it.
Start with foreach. It covers 80% of use cases. Add for when you need index control. Reach for while when you are polling or waiting. And always — always — put an exit condition on your while loops. The client whose SQL Server polling script ran for 72 hours because someone forgot a counter will back me up on that one.
If your team is drowning in repetitive manual work and needs help building automation that actually holds up in production, reach out to us. This is exactly the kind of toil we eliminate for our managed customers.


