DEV Community

Cover image for PowerShell ForEach-Object vs foreach: The Real Difference (and Why return Doesn't Stop Your Loop)
arnostorg
arnostorg

Posted on

PowerShell ForEach-Object vs foreach: The Real Difference (and Why return Doesn't Stop Your Loop)

You wrote return inside a loop to stop at the first match. The loop kept going. Or you wrote break to skip the rest of one inner loop, and your whole outer loop quietly ended.

Both bugs come from the same mix-up: ForEach-Object and the foreach statement look alike, but they are different things. One is a cmdlet that runs a script block per pipeline item. The other is a language loop. return, break, and continue behave differently in each.

Everything below was run in PowerShell 7.4.6. The same rules apply in Windows PowerShell 5.1.

The shortest honest comparison

ForEach-Object (cmdlet) foreach (...) { } (statement)
Input Pipeline, one item at a time A collection already in memory
Current item $_ / $PSItem Your own variable ($file, $n)
return Skips to the next item (like continue) Leaves the function or script
break Looks for an enclosing loop outside the pipeline Exits this loop
Pipe the result onward Works directly Needs $( ) or a variable first
Speed on big in-memory lists Slower (per-item script block call) Faster

One extra trap: when foreach appears after a pipe, it is not the statement. It is an alias:

(Get-Alias foreach).Definition
# ForEach-Object
Enter fullscreen mode Exit fullscreen mode

So Get-ChildItem | foreach { ... } is ForEach-Object, with all of its rules. Microsoft Learn's about_Foreach covers the statement, and ForEach-Object covers the cmdlet.

Why return doesn't stop your loop

1..5 | ForEach-Object {
    if ($_ -eq 3) { return }
    "item $_"
}
Enter fullscreen mode Exit fullscreen mode
item 1
item 2
item 4
item 5
Enter fullscreen mode Exit fullscreen mode

The return only left the script block for item 3. ForEach-Object then called the same script block for item 4. In practice return inside ForEach-Object acts like continue in a normal loop (about_Return).

The foreach statement does what most people expected:

function Test-Foreach {
    foreach ($n in 1..5) {
        if ($n -eq 3) { return }
        "item $n"
    }
    'after loop'
}

Test-Foreach
Enter fullscreen mode Exit fullscreen mode
item 1
item 2
Enter fullscreen mode Exit fullscreen mode

Here return left the whole function, so even 'after loop' never ran. If you only want to skip one item, use continue:

foreach ($n in 1..5) {
    if ($n -eq 3) { continue }
    "item $n"
}
# item 1, item 2, item 4, item 5
Enter fullscreen mode Exit fullscreen mode

Why break kills your outer loop

This one is nastier, because nothing errors:

foreach ($outer in 'A', 'B') {
    1..3 | ForEach-Object {
        if ($_ -eq 2) { break }
        "$outer$_"
    }
}
'after outer'
Enter fullscreen mode Exit fullscreen mode
A1
after outer
Enter fullscreen mode Exit fullscreen mode

B never ran. The ForEach-Object script block is not a loop, so break went looking for the nearest enclosing loop and found the outer foreach. Microsoft Learn's about_Break warns about this directly: break inside a pipeline such as a ForEach-Object script block can exit far more than the pipeline. With no enclosing loop at all, it can stop the whole script.

If you need to stop early, pick a tool built for it:

# Stop the pipeline after the first N results
1..1000000 | ForEach-Object { $_ } | Select-Object -First 3
# 1, 2, 3

# Or use statements and a labeled break
:outer foreach ($a in 1..3) {
    foreach ($b in 1..3) {
        if ($b -eq 2) { break outer }
        "$a-$b"
    }
}
# 1-1
Enter fullscreen mode Exit fullscreen mode

Select-Object -First stops the upstream pipeline once it has enough items. Labels are explained in about_Break and about_Continue.

Piping and speed

The foreach statement can't sit at the start of a pipeline:

foreach ($n in 1..3) { $n } | Sort-Object -Descending
# An empty pipe element is not allowed.
Enter fullscreen mode Exit fullscreen mode

Wrap it in $( ), or assign it first:

$(foreach ($n in 1..3) { $n }) | Sort-Object -Descending
# 3, 2, 1

$doubled = foreach ($n in 1..3) { $n * 2 }
$doubled -join ','
# 2,4,6
Enter fullscreen mode Exit fullscreen mode

On speed, I summed 200,000 numbers both ways on the same box, three runs each. The foreach statement took about 150 to 300 ms. ForEach-Object took about 740 to 900 ms. Your numbers will differ, but the gap is normal: the cmdlet invokes a script block for every item.

That doesn't make ForEach-Object the wrong choice. It streams, so it starts working on the first file while Get-ChildItem is still finding the rest, and it never needs the whole list in memory. It also has -Begin and -End blocks for setup and totals:

1..4 | ForEach-Object -Begin { $t = 0 } -Process { $t += $_ } -End { "total $t" }
# total 10
Enter fullscreen mode Exit fullscreen mode

Quick checklist

  • Data already in a variable, and you want speed or real break/return: use the foreach statement.
  • Streaming a big pipeline (files, log lines, remote results): use ForEach-Object.
  • Want to skip one item inside ForEach-Object: return works, and that's the only thing it does there.
  • Want to stop a pipeline early: Select-Object -First, not break.
  • Seeing | foreach { in a script: that's ForEach-Object, not the statement.
  • Need to pipe a foreach statement's output: wrap it in $( ) or assign it first.

Practice loops in a real terminal

Reading about loop control is quick; spotting it inside someone else's script is the hard part. CMD Master's free interactive PowerShell, CMD, and Bash terminal in the browser lets you run these loops with no install and see exactly where return and break land.

Have you ever had a break inside ForEach-Object end more of your script than you meant it to?

Top comments (0)