This guide assumes Powershell 7+ (the JIT and BCL are markedly faster than 5.1) and that you can run scripts that import a few System.* namespaces.

Powershell cmdlets are friendly, composable, and slow. Most of the time that doesn't matter a 50ms cmdlet inside a 5-second script is rounding error. But once you start processing tens of thousands of items, the wrapper overhead becomes the script. This post is a tour of the cases where dropping into the underlying .NET APIs is worth the extra verbosity, with benchmarks for each.

Rule of thumb: if a cmdlet wraps a single .NET call and you're calling it in a loop, the .NET call is faster. If you're calling it once on a 10-element collection, the cmdlet is fine.

How to Benchmark Honestly

Every comparison below uses the same harness:

function Measure-It
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [scriptblock]$Action,
        [int]$Iterations = 5
    )
    # Warm up - JIT, caches, branch predictor
    for ($i = 0; $i -lt 2; $i++) { & $Action | Out-Null }

    $times = for ($i = 0; $i -lt $Iterations; $i++) {
        (Measure-Command { & $Action }).TotalMilliseconds
    }

    [pscustomobject]@{
        MinMs    = ($times | Measure-Object -Minimum).Minimum
        MedianMs = ($times | Sort-Object)[[int]($times.Count / 2)]
        MaxMs    = ($times | Measure-Object -Maximum).Maximum
    }
}

Always run a couple of warmup iterations and report the median, not the mean. The first run includes JIT compilation and the slowest run is usually a GC pause neither tells you the steady-state cost.

All numbers below are from a single test run on Powershell 7.4 on a Ryzen-class workstation. Your mileage will vary, but the ratios are what matter.

File I/O

Reading a Large Text File

Cmdlet:

$lines = Get-Content .\big.log

Get-Content returns one element per line and runs each through the pipeline. For a 500 MB log file, that's millions of pipeline invocations.

.NET:

$lines = [System.IO.File]::ReadAllLines('.\big.log')
Approach 500 MB file Notes
Get-Content ~28,000 ms One pipeline event per line
Get-Content -Raw ~1,400 ms Returns one giant string
[IO.File]::ReadAllLines() ~1,100 ms Returns string[]
[IO.File]::ReadAllText() ~900 ms Returns one giant string

If you need to grep, use Select-String -Path directly it streams and never materializes the file. The fastest way to count matching lines is still [IO.File]::ReadLines() in a foreach, which streams without allocating.

Writing a Large Text File

# Cmdlet
$lines | Out-File .\out.txt

# Cmdlet, slightly better
$lines | Set-Content .\out.txt

# .NET
[System.IO.File]::WriteAllLines('.\out.txt', $lines)
Approach 1M lines
Out-File ~9,500 ms
Set-Content ~6,200 ms
[IO.File]::WriteAllLines() ~700 ms
StreamWriter in a foreach ~650 ms

Out-File is the slowest because it goes through the formatting subsystem (the same one that builds the table view in your console).

String Building

The single most common Powershell performance bug:

# DON'T DO THIS
$s = ''
foreach ($x in $items) { $s += "$x`n" }

Strings are immutable in .NET. Every += allocates a new string and copies the old one. That's O(n²) work for n appends.

# Use StringBuilder
$sb = [System.Text.StringBuilder]::new()
foreach ($x in $items) { [void]$sb.AppendLine($x) }
$s = $sb.ToString()
Approach 100k items 1M items
$s += "$xn"` ~7,000 ms (don't try)
-join ~80 ms ~900 ms
StringBuilder ~70 ms ~750 ms

-join is almost as fast as StringBuilder and a one-liner. Reach for StringBuilder only when you're conditionally appending across many branches.

Collection Building

Same disease, different cmdlet:

# DON'T DO THIS
$out = @()
foreach ($x in $items) { $out += $x }

Same O(n²) pattern Powershell arrays are immutable; += allocates and copies the whole array every time.

Three faster options:

# A. List<T> - best for known item types
$list = [System.Collections.Generic.List[object]]::new()
foreach ($x in $items) { $list.Add($x) }

# B. Pipeline output capture
$out = foreach ($x in $items) { Process $x }

# C. ArrayList - works but uses object[] internally; prefer List<T>
$al = [System.Collections.ArrayList]::new()
foreach ($x in $items) { [void]$al.Add($x) }
Approach 100k items
+= to array ~14,000 ms
ArrayList ~80 ms
List<object> ~50 ms
Pipeline capture ~40 ms

The pipeline-capture pattern ($out = foreach (...) { ... }) is both the fastest and the most idiomatic. Use it whenever you can.

Hashing

# Cmdlet - opens the file, reads it through the pipeline, formats output
Get-FileHash .\big.bin -Algorithm SHA256

# .NET - direct
$sha    = [System.Security.Cryptography.SHA256]::Create()
$stream = [System.IO.File]::OpenRead('.\big.bin')
try { $hash = $sha.ComputeHash($stream) } finally { $stream.Dispose(); $sha.Dispose() }
[BitConverter]::ToString($hash) -replace '-'
Approach 1 GB file
Get-FileHash ~5,200 ms
SHA256.Create() ~3,800 ms

About 25% faster, mostly because you skip the cmdlet's output formatting. The bigger win shows up when hashing many small files in a loop:

Approach 10,000 small files
Get-FileHash per file ~28,000 ms
Reused SHA256 instance ~3,200 ms

Reusing the hasher across files (instead of letting Get-FileHash create a new one every call) is where the real gain lives.

HTTP

# Cmdlet
$resp = Invoke-RestMethod 'https://api.example.com/items'

# .NET
$client = [System.Net.Http.HttpClient]::new()
try
{
    $json = $client.GetStringAsync('https://api.example.com/items').GetAwaiter().GetResult()
    $resp = $json | ConvertFrom-Json
} finally { $client.Dispose() }

For a single request these are within noise of each other. The interesting case is many requests:

Approach 1,000 sequential requests
Invoke-RestMethod per call ~38,000 ms
Single HttpClient reused ~12,000 ms
HttpClient + ForEach -Parallel ~2,200 ms

Invoke-RestMethod creates and tears down a new HttpClient (and its TCP connection) every call. Reusing one HttpClient keeps connections alive that's the whole win.

Never new HttpClient() per call in real code. Either use one long-lived instance, or use IHttpClientFactory if you're inside a hosted app. The cmdlet's per-call cost is a real bug, not a quirk.

JSON

ConvertFrom-Json calls System.Text.Json under the hood in PS 7+. For most workloads they're identical. The exception is deeply nested or repetitive JSON, where you want to skip Powershell's PSCustomObject materialization:

$doc = [System.Text.Json.JsonDocument]::Parse((Get-Content .\big.json -Raw))
foreach ($el in $doc.RootElement.EnumerateArray())
{
    $name = $el.GetProperty('name').GetString()
    # ...
}
$doc.Dispose()
Approach 50 MB JSON, iterate 1M values
ConvertFrom-Json + .foreach ~9,800 ms
JsonDocument walking ~1,400 ms

The cost of ConvertFrom-Json isn't parsing it's allocating one PSCustomObject per object. If you're going to throw 99% of them away, walk the JsonDocument directly.

Regex

# Cmdlet (uses [regex] under the hood, but recompiles per call)
$lines | Where-Object { $_ -match '^\d{4}-\d{2}-\d{2}' }

# .NET - compile once, reuse
$rx = [regex]::new('^\d{4}-\d{2}-\d{2}', 'Compiled')
$lines | Where-Object { $rx.IsMatch($_) }
Approach 1M lines
-match operator ~3,800 ms
Cached [regex] (no Compiled) ~2,900 ms
Cached [regex] (Compiled) ~1,500 ms

The Compiled flag has a one-time JIT cost (~50 ms). For any regex you're going to run more than a few thousand times, it pays for itself immediately.

XML

Select-Xml is convenient but slow. For high-volume XML, use XmlDocument directly:

$doc = [System.Xml.XmlDocument]::new()
$doc.Load('.\events.xml')
$nodes = $doc.SelectNodes('//Event[@severity="High"]')
Approach 100 MB XML
Select-Xml ~14,000 ms
XmlDocument.SelectNodes ~3,200 ms
XmlReader (streaming) ~1,800 ms

For XML you only walk once, XmlReader is fastest and uses constant memory regardless of file size.

Sorting Large Collections

Sort-Object is brutally slow on large collections because it uses pipeline objects and a stable sort with reflection-based property access:

# Cmdlet
$sorted = $items | Sort-Object Name

# .NET LINQ
$sorted = [Linq.Enumerable]::OrderBy($items, [Func[object,string]] { param($x) $x.Name })
Approach 1M strings
Sort-Object ~22,000 ms
[Array]::Sort($arr) ~250 ms
LINQ OrderBy ~900 ms

[Array]::Sort() is the right tool when you have a homogeneous primitive array. For object property sorts, LINQ's OrderBy is ~25x faster than Sort-Object.

Where the Cmdlets Are Actually Faster

It's not always one-way. A few cases where the cmdlet is the right call:

  • Anything that runs once or twice. The .NET version's verbosity is not worth shaving 100 ms off a 10-second script.
  • Compare-Object for small sets the .NET equivalent (Except/Intersect) needs custom equality comparers for PSObjects and ends up about the same speed for less than ~10k items.
  • ConvertTo-Json / ConvertFrom-Json when you actually want PSCustomObject output. The wrapper cost is the feature in that case.
  • Anything that needs -WhatIf / -Confirm you'd have to reimplement that whole layer.
  • Anything where the cmdlet wraps several .NET calls into one operation e.g. Get-ChildItem -Recurse does enumeration plus filtering plus error handling that [IO.Directory]::EnumerateFiles() doesn't, even though the latter is faster on the happy path.

Patterns That Stay Fast Either Way

A handful of habits make any Powershell faster, cmdlet or not:

  • Stream, don't collect. foreach over the pipeline; never @() something you're about to filter further.
  • .Where() and .ForEach() methods beat Where-Object / ForEach-Object by 2–5x because they skip the pipeline machinery.
  • Pre-size your collections. [List[object]]::new(1000000) skips a dozen reallocations.
  • Use typed variables in hot loops. [int]$i = 0; while ($i -lt $n) { ... } avoids boxing.
  • Avoid Write-Host in tight loops. Even Write-Verbose has measurable cost when called millions of times.

When to Reach for .NET, in One Table

Situation Use
> 100k iterations of anything .NET
Hot loop inside a long-running service .NET
Reusable HTTP / DB / hash client .NET
Building strings or collections in a loop .NET (or -join / pipeline capture)
Anything called once during a script Cmdlet
Anything where you want -WhatIf Cmdlet
Reading the latest line of a small log Cmdlet
Quick interactive one-liner Cmdlet

What to Do Next

Cmdlets are an interface layer: friendly, formatted, pipeline-aware, designed for human-scale workloads. The .NET APIs underneath them are designed for machine-scale workloads. Both are good PowerShell. The skill is knowing which one you're writing in any given line of code, and switching when the numbers say to.

Three concrete moves to apply this:

  1. Pick the slowest script in your team's automation. Run the Measure-It harness from the post against the three hottest cmdlet calls inside it. The bottleneck is almost never where you guessed.
  2. Replace one += to an array in a loop with [List[object]]::new() in the script you most recently committed. Re-run, measure. The speedup will pay for the read.
  3. For your next file-parsing script, default to [System.IO.File]::ReadAllLines() instead of Get-Content. Cmdlet streaming is great when you need the streaming; for "read this 50 MB log into memory once", the .NET API is an order of magnitude faster.

Pairs naturally with the parallelism post (the second axis to optimize after switching to .NET APIs is whether the work is parallelisable at all) and with the C# module series (the natural endpoint when "drop into .NET" becomes the whole script, not just a hot loop).