PowerShell Parallel Foreach: Speed Up with ForEach -Parallel

A sequential loop that queries 200 servers takes as long as the slowest server multiplied by 200. PowerShell 7’s PowerShell foreach parallel feature — the -Parallel parameter of ForEach-Object — runs iterations in multiple threads simultaneously, collapsing that wait time to roughly the duration of the slowest single operation. This post covers the syntax, throttle limit, thread-safe variable access, the $using: scope modifier, error handling inside parallel blocks, and when to use jobs instead.
ForEach-Object -Parallel Syntax
ForEach-Object -Parallel requires PowerShell 7.0 or later. Check your version with $PSVersionTable.PSVersion before using it. The syntax is identical to regular ForEach-Object except for the -Parallel switch:
#Requires -Version 7.0
$servers = Get-Content "C:\Scripts\servers.txt"
$results = $servers | ForEach-Object -Parallel {
$ping = Test-Connection -ComputerName $_ -Count 1 -Quiet
[PSCustomObject]@{
Server = $_
Online = $ping
}
}
$results | Sort-Object Server | Format-Table -AutoSize
Server Online
------ ------
Server01 True
Server02 True
Server03 False
ThrottleLimit for Concurrency Control
By default, ForEach-Object -Parallel runs up to five threads simultaneously. Increase -ThrottleLimit for I/O-bound tasks like pinging or web requests, where threads spend most of their time waiting:
$servers = Get-Content "C:\Scripts\servers.txt" # 200 servers
$results = $servers | ForEach-Object -Parallel {
[PSCustomObject]@{
Server = $_
Online = (Test-Connection -ComputerName $_ -Count 1 -Quiet)
Checked = Get-Date
}
} -ThrottleLimit 50
Write-Host "$($results.Where({ $_.Online }).Count) of $($results.Count) servers online"
For CPU-bound tasks, keep -ThrottleLimit at or below your logical processor count to avoid context-switching overhead. Use (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors to query the count.
Thread-Safe Variable Access
Each parallel thread has an isolated runspace. Standard PowerShell variables defined outside the block are not accessible inside it. Output objects by writing them to the pipeline — ForEach-Object -Parallel collects all pipeline output from all threads and returns it to your session.
Avoid shared mutable state. If you need to accumulate results into a collection thread-safely, use a [System.Collections.Concurrent.ConcurrentBag[object]]:
$bag = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
1..20 | ForEach-Object -Parallel {
$localBag = $using:bag
$localBag.Add([PSCustomObject]@{ Id = $_; Thread = [Threading.Thread]::CurrentThread.ManagedThreadId })
} -ThrottleLimit 5
$bag | Sort-Object Id | Format-Table
Using $using: for Outer Variables
The $using: scope modifier captures a variable from the parent scope as a read-only copy inside the parallel block. This is the correct way to pass configuration values, credential objects, or file paths into each thread:
$logPath = "C:\Logs"
$credential = Get-Credential
$servers = Get-Content "C:\Scripts\servers.txt"
$servers | ForEach-Object -Parallel {
$server = $_
$path = $using:logPath
$cred = $using:credential
$result = Invoke-Command -ComputerName $server -Credential $cred -ScriptBlock {
Get-EventLog -LogName Application -Newest 10 -EntryType Error
}
$result | Export-Csv "$path\${server}_errors.csv" -NoTypeInformation -Append
} -ThrottleLimit 20
Error Handling in Parallel
Errors inside parallel blocks are collected and surfaced after all threads complete. Wrap the body in try/catch to handle per-iteration errors without stopping other threads:
$servers = Get-Content "C:\Scripts\servers.txt"
$errors = [System.Collections.Concurrent.ConcurrentBag[string]]::new()
$results = $servers | ForEach-Object -Parallel {
$errBag = $using:errors
try {
$info = Get-CimInstance -ComputerName $_ -ClassName Win32_OperatingSystem -ErrorAction Stop
[PSCustomObject]@{ Server = $_; OS = $info.Caption; OK = $true }
}
catch {
$errBag.Add("$_ : $($_.Exception.Message)")
[PSCustomObject]@{ Server = $_; OS = $null; OK = $false }
}
} -ThrottleLimit 25
Write-Host "Completed: $($results.Count) | Errors: $($errors.Count)"
if ($errors.Count) { $errors | ForEach-Object { Write-Warning $_ } }
Jobs vs -Parallel Performance
ForEach-Object -Parallel uses runspaces, which are lightweight compared to the full separate PowerShell processes that Start-Job spawns. For large-scale parallel work, -Parallel starts faster, uses less memory, and scales better. Use Start-Job when you need to run code on a remote machine, need a completely isolated environment, or are targeting PowerShell 5.1 where -Parallel is not available.
Common Errors and Fixes
-
$using: required for outer scope variables — $var directly won’t work.
$logPathinside a parallel block is undefined unless you reference it as$using:logPath. Direct variable access returns$nullsilently. Always prefix outer variables with$using:in parallel script blocks. - Parallel output ordering is not guaranteed. Threads complete in any order, so the results collection will not match the input order. If order matters, sort the output by a key property after collection.
Related Cmdlets / See Also
Wrapping Up
ForEach-Object -Parallel is the cleanest way to parallelize loops in PowerShell 7. Use $using: for outer variables, set -ThrottleLimit based on whether your workload is I/O or CPU bound, wrap loop bodies in try/catch for resilience, and always sort output if order matters.


