PowerShell ForEach-Object -Parallel: Patterns and Pitfalls

PowerShell 7.0 added ForEach-Object -Parallel as a first-class parallel loop that runs script blocks across multiple runspaces without the ceremony of background jobs. Knowing the essential powershell foreach-object parallel patterns — especially $using: scope, ThrottleLimit tuning, and safe error collection — separates scripts that actually scale from ones that silently lose data or deadlock. This guide covers all of it with benchmarks and the cases where runspaces still win.
Basic -Parallel Syntax and ThrottleLimit Tuning
ForEach-Object -Parallel runs each input object’s script block in a separate runspace. The -ThrottleLimit parameter caps how many runspaces run concurrently. The default is 5 — almost always too low for I/O-bound work and sometimes too high for CPU-bound work.
# Requires PowerShell 7.0+
$servers = 1..20 | ForEach-Object { "server-$_" }
$results = $servers | ForEach-Object -Parallel {
$server = $_
[PSCustomObject]@{
Server = $server
Latency = (Test-Connection $server -Count 1 -ErrorAction SilentlyContinue)?.Latency ?? -1
}
} -ThrottleLimit 10 -TimeoutSeconds 30
$results | Sort-Object Server | Format-Table -AutoSize
ThrottleLimit guidance:
- I/O-bound work (network calls, file reads): set 2–4× the number of logical CPU cores.
- CPU-bound work (compression, cryptography): set equal to the number of logical cores (
$env:NUMBER_OF_PROCESSORS). - WinRM / SSH remote sessions: stay at 10–20 to avoid overwhelming targets.
Accessing Outer Scope Variables with $using:
Each parallel runspace is isolated — variables defined outside the script block are not automatically available inside it. Use the $using: scope modifier to capture outer variables by value at the time the parallel block starts.
$logPath = 'C:\Logs\parallel-run.log'
$retryCount = 3
$credential = Get-Credential
$servers | ForEach-Object -Parallel {
# Must use $using: to access outer-scope variables
$log = $using:logPath
$retries = $using:retryCount
$cred = $using:credential
for ($i = 0; $i -lt $retries; $i++) {
try {
$result = Invoke-Command -ComputerName $_ -Credential $cred -ScriptBlock {
Get-Service -Name 'wuauserv'
} -ErrorAction Stop
break
}
catch {
if ($i -eq ($retries - 1)) {
Add-Content -Path $log -Value "FAILED: $_ — $_"
}
}
}
} -ThrottleLimit 8
$using: captures a snapshot of the value at invocation time. Modifying the outer variable after the parallel block starts has no effect on running runspaces, and modifying a $using:-referenced variable inside the block does not mutate the outer variable.
Collecting Return Values and Errors from Parallel Blocks
Output emitted by each script block flows back to the parent pipeline. Errors written to the error stream are collected but require explicit handling to surface them cleanly.
$errorList = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
$results = 1..10 | ForEach-Object -Parallel {
$errors = $using:errorList
$n = $_
try {
if ($n -eq 5) { throw "Simulated failure on item $n" }
[PSCustomObject]@{ Item = $n; Status = 'OK'; Value = $n * 2 }
}
catch {
$errors.Add([PSCustomObject]@{ Item = $n; Error = $_.Exception.Message })
[PSCustomObject]@{ Item = $n; Status = 'Error'; Value = $null }
}
} -ThrottleLimit 4
Write-Host "Results: $($results.Count)"
Write-Host "Errors collected: $($errorList.Count)"
$errorList | Format-Table -AutoSize
Using a ConcurrentBag from System.Collections.Concurrent is the safe way to collect errors across parallel runspaces. Standard arrays and ArrayList are not thread-safe and will corrupt data under concurrent writes.
Performance Benchmarks: Sequential vs Parallel
Measure before you parallelize — the runspace creation overhead makes parallelism counterproductive for fast operations on small sets.
$items = 1..50
# Sequential
$seqTime = Measure-Command {
$items | ForEach-Object { Start-Sleep -Milliseconds 200 }
}
# Parallel with ThrottleLimit 10
$parTime = Measure-Command {
$items | ForEach-Object -Parallel {
Start-Sleep -Milliseconds 200
} -ThrottleLimit 10
}
Write-Host "Sequential: $($seqTime.TotalSeconds.ToString('F2'))s"
Write-Host "Parallel: $($parTime.TotalSeconds.ToString('F2'))s"
Write-Host "Speedup: $([math]::Round($seqTime.TotalSeconds / $parTime.TotalSeconds, 1))x"
# Sequential: ~10.00s | Parallel: ~1.20s | Speedup: ~8.3x
For trivial operations (string manipulation, in-memory calculations under ~5ms each), the runspace startup cost can make parallel execution slower than sequential. Always use Measure-Command to validate the benefit.
Thread Safety: Avoiding Shared State Corruption
Parallel runspaces share memory, but standard PowerShell collections are not thread-safe. Writing to the same variable, file, or collection from multiple parallel blocks without synchronization causes data corruption and intermittent bugs.
# UNSAFE — concurrent writes to a normal ArrayList
$unsafeList = [System.Collections.ArrayList]::new()
1..20 | ForEach-Object -Parallel {
# RACE CONDITION — do not do this
($using:unsafeList).Add($_) | Out-Null
}
# SAFE — use ConcurrentBag for unordered collection
$safeBag = [System.Collections.Concurrent.ConcurrentBag[int]]::new()
1..20 | ForEach-Object -Parallel {
($using:safeBag).Add($_) | Out-Null
} -ThrottleLimit 4
Write-Host "Safe collected: $($safeBag.Count) items"
# SAFE — let the pipeline collect return values (no shared state needed)
$output = 1..20 | ForEach-Object -Parallel {
$_ * 2 # just return the value; pipeline collects it
} -ThrottleLimit 4
Write-Host "Pipeline collected: $($output.Count) items"
When to Use Runspaces Instead of -Parallel
ForEach-Object -Parallel is convenient but has overhead per-item. For very large item counts or fine-grained runspace control, the RunspacePool API gives you more efficiency.
# Runspace pool pattern for high-volume parallel work
$pool = [RunspaceFactory]::CreateRunspacePool(1, 20)
$pool.Open()
$jobs = foreach ($server in $servers) {
$ps = [PowerShell]::Create()
$ps.RunspacePool = $pool
[void]$ps.AddScript({ param($s) Test-Connection $s -Count 1 -Quiet }.ToString())
[void]$ps.AddArgument($server)
@{ PS = $ps; Handle = $ps.BeginInvoke() }
}
$results = foreach ($job in $jobs) {
$job.PS.EndInvoke($job.Handle)
$job.PS.Dispose()
}
$pool.Close()
$pool.Dispose()
Write-Host "Runspace pool complete: $($results.Count) results"
Use runspace pools when you need consistent throughput for tens of thousands of items, when you want to reuse runspaces across loop iterations, or when you need finer-grained control over runspace state.
Common Errors and Fixes
Variables set inside the -Parallel block are not visible outside it. Each runspace is isolated. Variables created or modified inside the parallel script block exist only within that runspace. To surface values, return them as output objects and assign the result of the ForEach-Object call to a variable, or write to a thread-safe shared collection via $using:.
Importing modules inside -Parallel is required — outer scope imports do not carry over. A module imported before the parallel block is not available inside the runspace. Add Import-Module ModuleName as the first line inside the script block, or use the -InitializationScript parameter to set up the runspace environment once per runspace rather than once per item.
Related Cmdlets / See Also
- PowerShell Parallel ForEach Patterns
- PowerShell Background Jobs Guide
- Measuring PowerShell Performance with Measure-Command
Wrapping Up
ForEach-Object -Parallel is the right tool for most concurrent I/O work in PowerShell 7. Get $using: scope right, use thread-safe collections for shared state, tune ThrottleLimit based on workload type, and always benchmark before assuming parallelism helps. For high-volume scenarios, the runspace pool API offers the next level of control.


