Mert Özen Further With Every Line
DevOps PowerShell Scripting — #03

Conditions, Loops and Using the Pipeline in Scripts: switch, foreach, Where-Object, ForEach-Object and -Parallel

Mert Özen Nov 15, 2026 15 min 8 views
Conditions, Loops and Using the Pipeline in Scripts: switch, foreach, Where-Object, ForEach-Object and -Parallel

In PowerShell the foreach statement is the fastest on a collection in memory, while for data streaming from a file the pipeline does far less work because it can stop early; a break inside ForEach-Object skips the rest of the script. We show comparison operators, reading logs with switch -Regex, begin/process/end blocks and ForEach-Object -Parallel with a script that checks Bookstore's 200 book covers, along with the $using:, race condition and Format-Table traps.

Version note: The examples in this post were run with PowerShell 7.6.10 (LTS) and PowerShell 7.7.0 on Ubuntu 24.04; the measurements were taken on the same machine with both versions and gave the same results. Cmdlets and their output are the same on Windows; only paths start with C:\. Windows PowerShell 5.1 has no ForEach-Object -Parallel; those parts apply only to PowerShell 7.

Guess First

A maintenance script of Bookstore, the online bookstore used as the shared example across this site's series, has two loops written with bash habits of continue and break. One is ForEach-Object in a pipeline, the other a foreach statement:

1..5 | ForEach-Object {
    if ($_ -eq 3) { return }
    "ForEach-Object: $_"
}
foreach ($n in 1..5) {
    if ($n -eq 3) { continue }
    "foreach: $n"
}
1..5 | ForEach-Object {
    if ($_ -eq 3) { break }
    "before break: $_"
}
'after the pipeline'

When you run the script, what is the last line on the screen? Is after the pipeline printed? What does the return in the first block do: end the script, or skip only that element?

The previous post put the data into types. This post moves on to making decisions and looping over that data: comparison operators, switch, PowerShell's three different loop forms (the foreach statement, ForEach-Object and .ForEach()), using the pipeline inside a script with begin/process/end, and ForEach-Object -Parallel. At the end we will have a script that checks Bookstore's 200 book covers at the same time and writes the broken ones to a CSV file.

Comparison Operators and if

In PowerShell, < and > are reserved for redirection, so comparisons are written with a dash. The most common ones, on a line from the Bookstore API's access log:

PS> $line = '2026-11-15T04:58:21Z POST /api/payments 502 3004ms'
PS> $line -match ' (?<status>\d{3}) (?<ms>\d+)ms$'
True
PS> $Matches.status, $Matches.ms
502
3004
PS> $line -like '*/api/payments*'
True
PS> 502 -in 500, 502, 503, 504
True
PS> @(500, 502, 503, 504) -contains '502'
True
PS> '502' -is [int]
False
PS> [int]'502' -is [int]
True
PS> (Get-Content ./data/api-access.log) -match ' 404 '
2026-11-15T04:58:09Z GET /api/books/9780000000000 404 3ms
2026-11-15T04:58:31Z GET /covers/9780547928227.jpg 404 2ms

-like uses wildcards (*, ?), -match uses regex. When -match succeeds it puts the captured groups into the automatic $Matches hashtable; named groups ((?<status>...)) make it easier to read. Regex syntax itself is the subject of the Regular Expressions series. -in and -contains ask the same question from two directions: is the value in the list? Finding the string '502' on the right follows from the rule in the previous post; the left side is an int array, so the string was converted to int. -is does no conversion, it checks the type. The last line shows that -match also acts as a filter when there is an array on the left: the lines of the file that contain 404 came back.

if itself is like C#, except that elseif is one word. One difference: the condition does not have to be a bool. An empty string, 0, $null and an empty array count as false, everything else as true. The trap we saw with $null comparisons in the previous post comes from here.

switch: -Regex, -Wildcard and -File

switch is much more powerful than in C#, but you should not use it without knowing two differences:

PS> switch (404) { 404 { 'not found' } { $_ -ge 400 } { 'client error' } }
not found
client error
PS> switch (404) { 404 { 'not found'; break } { $_ -ge 400 } { 'client error' } }
not found
PS> switch -Wildcard ('Bookstore.Api.dll', 'appsettings.json', 'web.config') { '*.dll' { "binary: $_" } '*.json' { "config: $_" } default { "other: $_" } }
binary: Bookstore.Api.dll
config: appsettings.json
other: web.config

First, every matching condition runs; it does not stop at the first match as in C#. 404 matched both 404 and the $_ -ge 400 condition, so two lines were printed. Add break if you want to stop at the first match. A condition can be a value, a script block ({ $_ -ge 400 }), a wildcard with -Wildcard or a regex with -Regex. Second, if you give switch an array, it runs separately for each element; in the -Wildcard example the three file names were classified one by one.

Put together, these two features make switch a good tool for reading logs. The script below reads the access log line by line, counts the HTTP results and also returns the requests that took longer than a second:

$summary = [ordered]@{ ok = 0; clientError = 0; serverError = 0; slow = 0 }
$slowRequests = foreach ($line in Get-Content ./data/api-access.log) {
    switch -Regex ($line) {
        ' 2\d\d \d+ms$' { $summary.ok++ }
        ' 4\d\d \d+ms$' { $summary.clientError++ }
        ' 5\d\d \d+ms$' { $summary.serverError++ }
        ' (?<ms>\d+)ms$' {
            if ([int]$Matches.ms -ge 1000) {
                $summary.slow++
                $line
            }
        }
    }
}
[pscustomobject]$summary
$slowRequests
PS> ./lab/log-summary.ps1

ok clientError serverError slow
-- ----------- ----------- ----
 7           3           2    2
2026-11-15T04:58:21Z POST /api/payments 502 3004ms
2026-11-15T04:58:22Z GET /api/books 200 1290ms

The line that took 3004 ms was counted both as serverError and slow, because both conditions matched; that is the behaviour we want here. $Matches.ms comes from the last successful regex match. You can also give the file directly with switch -Regex -File ./data/api-access.log; then lines are read from the file one at a time.

foreach, ForEach-Object and .ForEach()

There are three ways to loop over a collection in PowerShell, and they are three different things:

  • foreach ($n in $numbers) { }: the language's loop statement. It takes the whole collection first, then loops. break and continue work as expected.
  • $numbers | ForEach-Object { }: a cmdlet. It processes each element from the pipeline as it arrives; the element is in $_ (or by its long name, $PSItem).
  • $numbers.ForEach({ }) and .Where({ }): methods PowerShell adds to every collection. They work on a collection already in memory and do not use the pipeline.

Doing the same job with 200,000 elements shows the difference:

$numbers = 1..200000
'foreach statement : {0,5:N0} ms' -f (Measure-Command { $r = foreach ($n in $numbers) { $n * 2 } }).TotalMilliseconds
'ForEach-Object    : {0,5:N0} ms' -f (Measure-Command { $r = $numbers | ForEach-Object { $_ * 2 } }).TotalMilliseconds
'.ForEach() method : {0,5:N0} ms' -f (Measure-Command { $r = $numbers.ForEach({ $_ * 2 }) }).TotalMilliseconds
'Where-Object      : {0,5:N0} ms' -f (Measure-Command { $r = $numbers | Where-Object { $_ % 7 -eq 0 } }).TotalMilliseconds
'.Where() method   : {0,5:N0} ms' -f (Measure-Command { $r = $numbers.Where({ $_ % 7 -eq 0 }) }).TotalMilliseconds
PS> ./lab/loops.ps1
foreach statement :    86 ms
ForEach-Object    :   413 ms
.ForEach() method :   174 ms
Where-Object      :   342 ms
.Where() method   :    65 ms

On a collection in memory, the foreach statement and .Where() are the fastest, while the cmdlets pay a pipeline cost per element. The numbers came out in the same order on 7.7.0. But speed is not the only measure. Let's look for the first 5xx line in a large file, a 56 MB log:

PS> (Get-Item ./lab/api-access-big.log).Length / 1MB
56.5528869628906
PS> (Measure-Command { $first = Get-Content ./lab/api-access-big.log | Where-Object { $_ -match ' 5\d\d ' } | Select-Object -First 1 }).TotalMilliseconds
11.877
PS> (Measure-Command { $first = foreach ($line in Get-Content ./lab/api-access-big.log) { if ($line -match ' 5\d\d ') { $line; break } } }).TotalMilliseconds
7357.0111

The pipeline finished in 12 milliseconds, foreach in 7.4 seconds. Select-Object -First 1 stops the pipeline once it has the first result, and Get-Content never reads the rest of the file. foreach ($line in Get-Content ...) first loads all 1.2 million lines into memory, and the loop only starts after that; the break inside cannot save it. The rule: foreach for a collection already in memory, the pipeline for data streaming from a file or another command.

The answer to Guess First also comes from this difference:

PS> ./lab/flow.ps1
ForEach-Object: 1
ForEach-Object: 2
ForEach-Object: 4
ForEach-Object: 5
foreach: 1
foreach: 2
foreach: 4
foreach: 5
before break: 1
before break: 2

The script block of ForEach-Object is not a loop; it is a script block called for each element. A return inside it ends only that call, so it behaves like continue and 3 was skipped. break, on the other hand, looks for a loop; since ForEach-Object is not a loop, it goes up to the nearest enclosing loop or to the end of the script. The script ended right there, and after the pipeline was never printed. The about_Break documentation also warns that break inside a pipeline does not just exit the pipeline but can end the whole runspace. To stop a pipeline early use Select-Object -First; to leave a loop use the foreach statement.

Using the Pipeline in Scripts: $_ and begin/process/end

ForEach-Object has three blocks: -Begin runs once before the first element, -Process for each element, -End once after the last element. This is the way to keep a total or a counter without breaking the pipeline:

PS> $inv = [cultureinfo]::InvariantCulture
PS> Import-Csv ./data/books.csv | ForEach-Object -Begin { $total = 0d } -Process { $total += [decimal]::Parse($_.Price, $inv) * [int]$_.Stock } -End { "stock value: $total" }
stock value: 1021.42
PS> Import-Csv ./data/books.csv | Where-Object { [int]$_.Stock -eq 0 } | ForEach-Object { "out of stock: $($PSItem.Title)" }
out of stock: The Hobbit
PS> $books = Import-Csv ./data/books.csv
PS> $books.Where({ [int]$_.Stock -lt 5 }, 'First', 2).Title
Emma
The Hobbit
PS> $books.ForEach('Title').Count
12

The books went through the pipeline one at a time, the total was written in -End, and at no point was the whole list held in an array. The second parameter of .Where() matters: 'First', 2 stops after the first two matches; there are modes such as 'Last', 'SkipUntil' and 'Split' too. Given a property name, .ForEach('Title') collects that property.

The same three blocks can be in the script itself. If a script takes input from the pipeline, its parameter gets [Parameter(ValueFromPipeline)] and its body is split into begin, process and end blocks; process runs once for each incoming element. Parameter attributes are covered in detail in the fifth post; here we use them in the cover script.

ForEach-Object -Parallel: ThrottleLimit and $using:

ForEach-Object -Parallel arrived with PowerShell 7.0. The script block runs in a separate runspace for each element, and at most -ThrottleLimit blocks run at the same time. According to the documentation the default is 5, and since 7.1 runspaces are reused from a pool. A separate runspace has two consequences. First, outside variables are invisible; as we saw in the previous post, they come in only as copies through $using:. Second, if several threads change an object brought in through $using: at the same time, the result can be corrupted:

PS> foreach ($run in 1..3) { $stats = @{ checked = 0 }; 1..2000 | ForEach-Object -ThrottleLimit 16 -Parallel { $s = $using:stats; $s.checked++ }; "run ${run}: $($stats.checked)" }
run 1: 1999
run 2: 2000
run 3: 2000
PS> foreach ($run in 1..3) { $n = (1..2000 | ForEach-Object -ThrottleLimit 16 -Parallel { 1 } | Measure-Object -Sum).Sum; "run ${run}: $n" }
run 1: 2000
run 2: 2000
run 3: 2000
PS> 1..3 | ForEach-Object -Parallel { $using:stats.checked = 0 }
ParserError: 
Line |
   1 |  1..3 | ForEach-Object -Parallel { $using:stats.checked = 0 }
     |                                    ~~~~~~~~~~~~~~~~~~~~
     | The assignment expression is not valid. The input to an assignment operator must be an
     | object that is able to accept assignments, such as a variable or a property.

Incrementing a hashtable 2,000 times from 16 threads gave 1,999 on the first run. No error, no warning, and different on every run. That is what a race condition typically looks like. The approach on the second line is correct every time: the blocks do not change anything, they produce results, and the single thread that collects the pipeline does the summing. The documentation also has an example with thread-safe types such as ConcurrentDictionary, but make this your first choice: return values from the parallel block, do not change shared state. The last line shows that assigning directly to a $using: variable is rejected even at parse time.

Bookstore: Checking 200 Book Covers in Parallel

The book covers on the catalog page come from /covers/<isbn>.jpg. Some have been deleted or were never uploaded; we want to find out which ones are broken. Lab note: the requests in this post go to the small version of Bookstore.Api from the earlier posts; it holds every cover request for 200 ms like a slow storage service and returns 404 for covers whose ISBN ends in 7. data/cover-isbns.txt holds 200 ISBNs.

#!/usr/bin/env pwsh
#Requires -Version 7.6

<#
.SYNOPSIS
    Checks Bookstore book cover URLs in parallel and reports the broken ones.

.DESCRIPTION
    Takes ISBNs from the pipeline or from -Path, sends a HEAD request to
    <BaseUrl>/covers/<isbn>.jpg for each one and returns one result per ISBN.
    With -OutFile, every cover that did not return HTTP 200 is written to a CSV file.

.PARAMETER Isbn
    An ISBN-13. Accepts pipeline input.

.PARAMETER Path
    A text file with one ISBN per line.

.PARAMETER BaseUrl
    Base address of the server that serves the covers.

.PARAMETER ThrottleLimit
    How many requests run at the same time.

.PARAMETER TimeoutSec
    How long to wait for each request, in seconds.

.PARAMETER OutFile
    Optional. CSV file for the broken covers.

.EXAMPLE
    ./Test-BookstoreCovers.ps1 -Path ../data/cover-isbns.txt -OutFile ./broken-covers.csv

.EXAMPLE
    Get-Content ../data/cover-isbns.txt | ./Test-BookstoreCovers.ps1 -ThrottleLimit 20
#>
[CmdletBinding()]
param(
    [Parameter(ValueFromPipeline)]
    [string] $Isbn,
    [string] $Path,
    [string] $BaseUrl = 'http://127.0.0.1:5080',
    [ValidateRange(1, 64)]
    [int] $ThrottleLimit = 10,
    [int] $TimeoutSec = 5,
    [string] $OutFile
)

begin {
    $isbns = [System.Collections.Generic.List[string]]::new()
    if ($Path) {
        foreach ($line in Get-Content -Path $Path) {
            if ($line.Trim()) { $isbns.Add($line.Trim()) }
        }
    }
}

process {
    if ($Isbn) { $isbns.Add($Isbn.Trim()) }
}

end {
    # Each block runs in its own runspace: outer variables come in only through $using:
    $results = $isbns | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
        # No progress bar for every single request
        $ProgressPreference = 'SilentlyContinue'
        $url = "$($using:BaseUrl)/covers/$_.jpg"
        $watch = [System.Diagnostics.Stopwatch]::StartNew()
        $statusCode = 0
        $errorMessage = $null
        try {
            $response = Invoke-WebRequest -Uri $url -Method Head -TimeoutSec $using:TimeoutSec -SkipHttpErrorCheck
            $statusCode = [int]$response.StatusCode
        }
        catch {
            # Connection refused, timeout, DNS: keep the reason, do not hide it
            $errorMessage = $_.Exception.Message
        }
        [pscustomobject]@{
            Isbn       = $_
            StatusCode = $statusCode
            Ms         = $watch.ElapsedMilliseconds
            Url        = $url
            Error      = $errorMessage
        }
    }

    $broken = @($results | Where-Object StatusCode -ne 200)
    Write-Verbose "Checked $(@($results).Count) covers, $($broken.Count) broken"
    if ($OutFile) {
        $broken | Sort-Object Isbn | Export-Csv -Path $OutFile -UseQuotes AsNeeded
    }
    $results
}

The script can take ISBNs two ways: from a file with -Path or from the pipeline. begin prepares the list and reads the file, process adds every ISBN coming from the pipeline, and the real work is in end: once the list is ready, the parallel check starts. Every parallel block returns a result object instead of writing to a shared list. The HEAD request asks only for the status without downloading the image. -SkipHttpErrorCheck keeps a 404 from becoming an exception, while catch only comes into play for connection errors and keeps the reason in the Error field. [CmdletBinding()] gives the script common parameters such as -Verbose.

PS> $results = ./scripts/Test-BookstoreCovers.ps1 -Path ./data/cover-isbns.txt -OutFile ./broken-covers.csv -Verbose
VERBOSE: Checked 200 covers, 17 broken
PS> $results | Group-Object StatusCode -NoElement

Count Name
----- ----
  183 200
   17 404
PS> Import-Csv ./broken-covers.csv | Select-Object -First 3 | Format-Table Isbn, StatusCode, Url

Isbn          StatusCode Url
----          ---------- ---
9782071752347 404        http://127.0.0.1:5080/covers/9782071752347.jpg
9782214415917 404        http://127.0.0.1:5080/covers/9782214415917.jpg
9782545564827 404        http://127.0.0.1:5080/covers/9782545564827.jpg
PS> Get-Content ./data/cover-isbns.txt -TotalCount 3 | ./scripts/Test-BookstoreCovers.ps1 | Format-Table Isbn, StatusCode, Ms

Isbn          StatusCode  Ms
----          ----------  --
9780183751821        200 206
9786265347364        200 208
9788908907478        200 205
PS> foreach ($t in 1, 5, 20, 50) { '{0,2} -> {1,6:N1} s' -f $t, (Measure-Command { ./scripts/Test-BookstoreCovers.ps1 -Path ./data/cover-isbns.txt -ThrottleLimit $t }).TotalSeconds }
 1 ->   41.6 s
 5 ->    8.3 s
20 ->    2.2 s
50 ->    1.1 s

17 of the 200 covers are broken. The results for the three ISBNs given through the pipeline came back in the order they finished, not the order they arrived; the order of parallel output is not guaranteed, so use Sort-Object afterwards if order matters. The last line shows the effect of -ThrottleLimit: a job that took 41.6 seconds one at a time dropped to 8.3 seconds with 5 parallel requests, 2.2 with 20 and 1.1 with 50. Since the work here is waiting on the network, parallelism brought an almost linear gain. As the documentation warns, for short CPU-bound work the cost of setting up each runspace eats the gain, and the parallel version can be slower than the sequential one.

In Production

Choose -ThrottleLimit for the other side, not for your own machine. If you check covers on a CDN or your own storage service, 50 parallel requests can hit that service's rate limit, or a nightly job can slow down live traffic. Somewhere between 10 and 20 is a reasonable start; lower it if the other side starts returning 429.

Do not lose errors in parallel blocks. An error that is not caught inside the block reaches the main script through the error stream, but it becomes harder to tell which element it belonged to. Catching the error and writing it into the result object, as this script does, makes both reporting and the "which one failed" question easier. The -TimeoutSeconds parameter also puts an upper limit on the whole parallel run; in nightly jobs it keeps one stuck request from holding up all the work.

Think about memory with large input. This script processes the ISBNs together in the end block, which is fine for 200 elements. With an input of millions of lines, though, the results should stream straight from the pipeline into Export-Csv instead of being collected in $results. In the Bash Scripting series we did the same job with xargs -P; in PowerShell, -Parallel returns results as objects, which makes the reporting side easier.

Common Pitfalls

Processing data after Format-Table. The Format-* cmdlets do not produce data, they produce formatting instructions for the screen. Pipe something after them and you process those instructions:

PS> $results = ./scripts/Test-BookstoreCovers.ps1 -Path ./data/cover-isbns.txt -ThrottleLimit 20
PS> $results | Where-Object StatusCode -ne 200 | Format-Table Isbn, StatusCode | Export-Csv ./broken-wrong.csv
PS> Get-Content ./broken-wrong.csv -TotalCount 3
"ClassId2e4f51ef21dd47e99d3c952918aff9cd","pageHeaderEntry","pageFooterEntry","autosizeInfo","shapeInfo","groupingEntry"
"033ecb2bc07a4d43b5ef94ed5a35d280",,,,"Microsoft.PowerShell.Commands.Internal.Format.TableHeaderInfo",
"9e210fe47d09416682b841769c78b8a3",,,,,
PS> $results | Format-Table Isbn, StatusCode | Select-Object -First 2 | Get-Member | Select-Object -First 1 TypeName

TypeName
--------
Microsoft.PowerShell.Commands.Internal.Format.FormatStartData

Instead of ISBNs, the CSV holds internal classes of the formatting system. Format-Table and Format-List always go at the very end of the pipeline, only when you are looking at the screen. Scripts return objects; the caller does the formatting.

Forgetting $using: inside -Parallel. Write $BaseUrl instead of $($using:BaseUrl) in the cover script and you get:

PS> Get-Content ./data/cover-isbns.txt -TotalCount 2 | ./lab/covers-nousing.ps1 | Format-List Isbn, StatusCode, Url, Error

Isbn       : 9786265347364
StatusCode : 0
Url        : /covers/9786265347364.jpg
Error      : Invalid URI: The hostname could not be parsed.

Isbn       : 9780183751821
StatusCode : 0
Url        : /covers/9780183751821.jpg
Error      : Invalid URI: The hostname could not be parsed.

$BaseUrl is not defined in the parallel runspace, it became an empty string as $null, and the URL was left without a host. Because we keep the error in the Error field, the reason is visible right away; with an empty catch block all 200 covers would have shown up as "broken" for no visible reason. That is exactly what happened in my first draft: I had used a parameter that Invoke-WebRequest does not have, catch swallowed that parameter binding error too, and the report showed 200 broken covers. We come back to what catch should catch in the fourth post; Set-StrictMode also helps catch this kind of error early.

Forgetting break in switch, and using break in ForEach-Object. As we saw above, both go against C# and bash habits: switch does not stop at the first match, and break inside ForEach-Object skips the rest of the script.

A Small Experiment

★☆☆ Rewrite log-summary.ps1 to use switch -Regex -File and get rid of Get-Content and foreach. Check that the result stays the same, then make the slow-request threshold a parameter.

★★☆ Call Test-BookstoreCovers.ps1 as Get-Content ./data/cover-isbns.txt | ./scripts/Test-BookstoreCovers.ps1 and count how many times the process block runs with a Write-Verbose line. If the ISBN list contains the same ISBN twice, how do you keep the script from sending two requests?

★★★ Try -ThrottleLimit values from 1 to 64 in powers of two and output the times as a table. After which point does the gain stop? Knowing that the lab API holds every request for 200 ms, calculate the theoretical shortest time for 200 covers and compare it with the measurement.

In the cover script we got by with a try/catch for error handling, and paid for it in the first draft. The next post is entirely about that: terminating and non-terminating errors, $ErrorActionPreference, try/catch/finally, exit codes of native commands such as dotnet and git, and a build script that stops the deploy when dotnet test fails.