foreach, ForEach-Object, and Pipeline Enumeration
Choose intentionally between the foreach language statement and ForEach-Object pipeline cmdlet, understand $_/$PSItem, preview begin/process/end, and avoid unsafe loop keywords in pipeline script blocks.
Learning objectives
- Distinguish the foreach language statement from the ForEach-Object cmdlet and explain why both forms exist.
- Use explicit foreach variables and $_/$PSItem where each makes current-object meaning clearest.
- Compare local in-memory iteration with streaming pipeline processing without premature optimization.
- Recognize begin/process/end as a lifecycle preview for later advanced functions.
- Use normal break/continue in language loops while avoiding those keywords inside ForEach-Object pipeline script blocks.
- Implement the same endpoint transformation both ways and justify the clearer engineering choice.
1. PowerShell has two common “for each item” forms because they fit different data flows
The foreach language statement iterates a collection already available to the script. The ForEach-Object cmdlet processes objects arriving through the pipeline. They can perform similar work, but their shape makes different intentions readable.
$targets = 'api-01', 'worker-01', 'cache-01'
foreach ($target in $targets) {
"foreach statement: $target"
}
$targets | ForEach-Object {
"ForEach-Object: $_"
}The first form names the current item explicitly as $target. In the pipeline form, $_—also available as $PSItem—means “the object currently being processed by this pipeline script block.”
2. foreach is often clearest when the whole collection is already in memory
The language statement makes surrounding control flow obvious. It is particularly readable when each iteration uses several statements, when you need break/continue, or when you are coordinating multiple pieces of local state.
$targets = @(
[pscustomobject]@{ Name='api-01'; Enabled=$true }
[pscustomobject]@{ Name='worker-01'; Enabled=$false }
[pscustomobject]@{ Name='cache-01'; Enabled=$true }
)
foreach ($target in $targets) {
if (-not $target.Enabled) {
continue
}
[pscustomobject]@{
Name = $target.Name
Action = 'Validate'
}
}The explicit variable name improves readability when the object appears several times. continue clearly means “skip the remainder of this loop iteration and move to the next target.”
3. ForEach-Object fits streaming pipelines
A pipeline can start producing downstream results before an upstream command has generated every object. ForEach-Object participates naturally in that flow and processes each incoming object in its -Process script block.
Get-Process |
Where-Object CPU -ne $null |
Select-Object -First 5 |
ForEach-Object {
[pscustomobject]@{
Name = $_.ProcessName
Id = $_.Id
}
}This form is expressive when the work is one stage of an object pipeline. Do not choose it merely because a pipeline looks compact; choose the form that makes the data flow easiest to understand.
4. $_ and $PSItem are names for the current pipeline object
Inside many pipeline script blocks, $_ is the conventional short form and $PSItem is the descriptive alias. Both refer to the same current object. Use a named variable in foreach; use $_/$PSItem where the pipeline context is obvious.
'api-01', 'worker-01' | ForEach-Object {
"short: $_ | descriptive: $PSItem"
}Avoid deeply nested pipeline script blocks that use $_ at several levels. At that point, named intermediate variables or a foreach statement often communicate intent better.
5. Build the same transformation both ways before choosing one
Suppose you need one output object per enabled target. Both implementations are valid; compare the reading experience.
$targets = @(
[pscustomobject]@{ Name='api-01'; Enabled=$true; Port=8443 }
[pscustomobject]@{ Name='worker-01'; Enabled=$false; Port=9000 }
[pscustomobject]@{ Name='cache-01'; Enabled=$true; Port=6379 }
)
$fromForeach = foreach ($target in $targets) {
if ($target.Enabled) {
[pscustomobject]@{ Endpoint = "$($target.Name):$($target.Port)" }
}
}
$fromPipeline = $targets |
Where-Object Enabled |
ForEach-Object {
[pscustomobject]@{ Endpoint = "$($_.Name):$($_.Port)" }
}The first emphasizes local control flow. The second emphasizes a staged pipeline: filter, then transform. Neither is universally superior.
6. Performance matters after clarity and workload size are known
A language foreach loop is often faster for in-memory collections because it avoids per-object cmdlet/pipeline overhead. But in typical DevOps automation, network requests, disk operations, remote calls, and external tools dominate runtime. Prefer the clearer form first, then measure when performance is material.
ForEach-Object -Parallel exists in PowerShell 7, but Chapter 17 teaches parallel work, runspaces, throttling, cancellation, and performance tradeoffs.7. begin/process/end are a preview of streaming lifecycle
ForEach-Object can accept a -Begin block once before input, a -Process block for each input item, and an -End block once after input. This pattern becomes important when you build advanced functions in Chapter 09.
$callbacks = @{
Begin = { $count = 0; $sum = 0 }
Process = { $count++; $sum += $_ }
End = { [pscustomobject]@{ Count=$count; Sum=$sum } }
}
10, 20, 30 | ForEach-Object @callbacksFor ordinary iteration, a single process script block is usually enough. The lifecycle form is shown now so later advanced-function syntax has a familiar mental model.
8. break, continue, and return are context-sensitive
Inside a language loop, break exits the loop and continue skips to the next iteration. Inside a pipeline script block, those keywords are not safe substitutes for “stop this pipeline item.” Microsoft documents that break or continue used inside a ForEach-Object pipeline script block can terminate the pipeline and potentially the entire runspace.
# Clear and safe: language-loop flow control.
foreach ($n in 1..5) {
if ($n -eq 2) { continue }
if ($n -eq 5) { break }
$n
}1
3
4For a pipeline, filter unwanted items with Where-Object, or structure the process block so it emits output only when appropriate. Reserve return for leaving the current script block/function context with full awareness of what that context is.
9. In pipelines, express “skip this item” as a filter or conditional emission
Instead of forcing loop-style continue into ForEach-Object, place the predicate in Where-Object or use an if block that simply emits nothing for unwanted objects.
$targets |
Where-Object Enabled |
ForEach-Object {
[pscustomobject]@{
Name = $_.Name
Endpoint = "$($_.Name):$($_.Port)"
}
}This keeps pipeline semantics explicit: filtering decides which objects continue; transformation changes the objects that remain.
10. Lab: produce an endpoint plan both ways and compare the engineering tradeoff
The same input is processed with a language loop and a pipeline. Verify that the data result is equivalent, then decide which form better communicates the task.
$targets = @(
[pscustomobject]@{ Name='api-01'; Role='api'; Port=8443; Enabled=$true }
[pscustomobject]@{ Name='api-02'; Role='api'; Port=8443; Enabled=$true }
[pscustomobject]@{ Name='worker-01'; Role='worker'; Port=9000; Enabled=$false }
)
$loopPlan = foreach ($target in $targets) {
if (-not $target.Enabled) { continue }
[pscustomobject]@{
Name = $target.Name
Uri = 'https://{0}:{1}/health' -f $target.Name, $target.Port
}
}
$pipelinePlan = $targets |
Where-Object Enabled |
ForEach-Object {
[pscustomobject]@{
Name = $_.Name
Uri = 'https://{0}:{1}/health' -f $_.Name, $_.Port
}
}
Compare-Object $loopPlan $pipelinePlan -Property Name, Uri
$pipelinePlan | Format-Table Name, Uri -AutoSizeIf Compare-Object emits no differences, both plans contain equivalent Name/Uri values. The learning goal is not to crown a winner—it is to choose intentionally based on control-flow readability versus pipeline composition.
Verification checklist
11. Common mistakes to avoid
Using ForEach-Object only because it is shorter. Shorter syntax is not automatically clearer control flow.
Using foreach when the real design is a simple filter/project pipeline. Preserve the object-pipeline model when it communicates the transformation better.
Using break or continue inside pipeline script blocks. These keywords can escape more context than intended; use filtering or explicit conditions instead.
Optimizing before measuring. Choose clarity first unless profiling shows iteration overhead matters.
12. Knowledge check
Question 1. What is the main structural difference between foreach and ForEach-Object?
Question 2. What do $_ and $PSItem represent inside a ForEach-Object process script block?
Question 3. When is a language foreach often clearer?
Question 4. Why avoid continue inside a ForEach-Object pipeline script block?
Question 5. What is begin/process/end useful for?
13. Summary
Use foreach when explicit local loop control makes the code easier to read, and ForEach-Object when iteration is naturally one stage of a pipeline. $_/$PSItem represent the current pipeline object; explicit variables make language loops readable. Keep loop-only control keywords out of pipeline script blocks, and measure before turning iteration style into a performance obsession.
14. Further reading
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.