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 @callbacks
For 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
4
For 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 -AutoSize
If 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.