PowerShell ForEach-Object vs foreach Statement: Full Comparison

They both iterate over a collection, they both use $_ to reference the current item, and beginners use them interchangeably — until one approach slows a script to a crawl or breaks a pipeline. PowerShell foreach vs ForEach-Object have fundamentally different behaviors: the foreach keyword loads the entire collection into memory at once, while ForEach-Object processes items one at a time as they stream through the pipeline. Choosing the right one depends on whether you’re in a pipeline and how much data you’re handling.
Syntax Comparison
Both loop over collections, but their positions in code are different. The foreach statement is a language construct; ForEach-Object is a cmdlet that sits in a pipeline.
# foreach statement — language construct
$servers = @("server01", "server02", "server03")
foreach ($server in $servers) {
Write-Output "Processing: $server"
}
# ForEach-Object — pipeline cmdlet (alias: %)
$servers | ForEach-Object {
Write-Output "Processing: $_"
}
# Both produce identical output in this simple case
# Assign results from foreach (collect all iterations)
$results = foreach ($s in $servers) {
[PSCustomObject]@{ Server = $s; Reachable = (Test-Connection $s -Count 1 -Quiet) }
}
# Assign results from ForEach-Object (also works)
$results = $servers | ForEach-Object {
[PSCustomObject]@{ Server = $_; Reachable = (Test-Connection $_ -Count 1 -Quiet) }
}
Pipeline Position Difference
This is the defining difference. ForEach-Object is a cmdlet and can sit anywhere in a pipeline. The foreach keyword is a statement — it cannot receive pipeline input or be mid-pipeline.
# ForEach-Object in the middle of a pipeline — works correctly
Get-ChildItem -Path "C:\Logs" -Filter "*.log" |
ForEach-Object { "$($_.Name) — $([math]::Round($_.Length/1KB,1)) KB" } |
Where-Object { $_ -like "*error*" }
# foreach keyword CANNOT be in the middle of a pipeline
# Get-ChildItem | foreach ($f in ??) { } -- this is invalid
If you need to iterate over pipeline results mid-stream, you must use ForEach-Object.
Memory Use: foreach Loads All, ForEach-Object Streams
The foreach statement requires the entire collection to be in memory before the first iteration begins. ForEach-Object processes one object at a time as it arrives from the pipeline — memory usage stays constant regardless of input size.
# foreach — entire file loaded into memory first
$lines = Get-Content -Path "C:\Logs\bigfile.log" # Loads ALL lines
foreach ($line in $lines) {
if ($line -match "ERROR") { Write-Output $line }
}
# ForEach-Object — one line at a time, constant memory
Get-Content -Path "C:\Logs\bigfile.log" |
ForEach-Object {
if ($_ -match "ERROR") { Write-Output $_ }
}
# Even simpler (no ForEach-Object needed here — just Where-Object)
Get-Content -Path "C:\Logs\bigfile.log" | Where-Object { $_ -match "ERROR" }
For files larger than a few hundred MB, the streaming approach of ForEach-Object (or Where-Object) can prevent out-of-memory conditions.
Performance Benchmarks
For small collections, foreach is typically faster because it avoids pipeline overhead. For large streaming scenarios, ForEach-Object wins on memory. Measure both when performance is critical.
# Simple benchmark comparison
$items = 1..10000
$time1 = Measure-Command {
foreach ($i in $items) { $null = $i * 2 }
}
$time2 = Measure-Command {
$items | ForEach-Object { $null = $_ * 2 }
}
Write-Output "foreach: $($time1.TotalMilliseconds) ms"
Write-Output "ForEach-Object: $($time2.TotalMilliseconds) ms"
Typical results show foreach is 3-5x faster for pure iteration of in-memory arrays, while ForEach-Object is preferred for streaming and pipeline composition.
Using $_ vs Named Variable
Inside ForEach-Object, the current item is $_ (or $PSItem). Inside a foreach statement, you name the variable yourself in the declaration.
"# foreach — named variable, clearer in complex blocks
foreach ($computer in $computerList) {
$diskFree = (Get-PSDrive -Name C -PSProvider FileSystem).Free
Write-Output "$computer has $([math]::Round($diskFree/1GB,1)) GB free"
}
# ForEach-Object — $_ can become confusing in nested scenarios
$computerList | ForEach-Object {
$computer = $_ # Assign to named variable for clarity in nested code
$diskFree = Invoke-Command -ComputerName $computer -ScriptBlock { (Get-PSDrive C).Free }
Write-Output "$computer has $([math]::Round($diskFree/1GB,1)) GB free"
}
When to Use Which
Decision guide:
- Use foreach when: iterating an in-memory array, performance matters most, not in a pipeline, named variables make the code clearer
- Use ForEach-Object when: receiving pipeline input, processing large files or streams, mid-pipeline position required, writing one-liners
Common Errors and Fixes
- foreach cannot be mid-pipeline: Writing
Get-Process | foreach ($p in ??) { }fails becauseforeachis a statement, not a cmdlet — it has no pipeline binding. The fix is simple: replace withGet-Process | ForEach-Object { }. In scripts where you’ve already assigned the collection to a variable, theforeachstatement is perfectly appropriate. - Breaking out of ForEach-Object uses return not break: Inside
ForEach-Object,breakexits the entire pipeline (including cmdlets before it), which is almost never what you want. Usereturnto skip to the next item (equivalent tocontinuein a traditional loop). To exit early fromForEach-Object, the only clean approach is to filter withWhere-Objectbefore the ForEach-Object block.
Related Cmdlets / See Also
Wrapping Up
For script clarity and moderate data sizes, foreach with a named variable wins on readability. For pipelines, streaming large data, or one-liners, ForEach-Object is the right tool. As a next step, profile a loop in one of your existing scripts with Measure-Command using both approaches — the results will help you internalize when each method performs better in your specific workload.


