PowerShell Parallel Foreach: Speed Up with ForEach -Parallel

PowerShell Parallel Foreach: Speed Up with ForEach -Parallel

PowerShell Tips Editor 4 min read
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. $logPath inside a parallel block is undefined unless you reference it as $using:logPath. Direct variable access returns $null silently. 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.

Send-Item -To