PowerShell Measure Script Performance with Measure-Command

PowerShell Measure Script Performance with Measure-Command

PowerShell Tips Editor 4 min read
PowerShell Measure Script Performance with Measure-Command

When a script takes ten minutes to complete and you suspect one section is the bottleneck, guessing wastes time. Measure-Command gives you precise timing data for any block of PowerShell code, making it the first tool to reach for when you want to PowerShell measure script performance. This post shows you how to benchmark individual commands, compare two approaches, profile large scripts section by section, and interpret the results correctly.

Quick Answer / TL;DR

Wrap any code in Measure-Command { ... } and check the .TotalSeconds or .TotalMilliseconds property on the returned TimeSpan object. Do a warm-up run first to exclude module loading time.

Basic Measure-Command Usage

Measure-Command executes the script block you provide and returns a TimeSpan object representing the elapsed time. The script block output is discarded — you see only timing data. Access specific time intervals through properties like .TotalSeconds, .TotalMilliseconds, or .Minutes.

# Time a simple command
$elapsed = Measure-Command {
    Get-ChildItem -Path C:\Windows -Recurse -ErrorAction SilentlyContinue
}

Write-Host "Elapsed: $($elapsed.TotalSeconds) seconds"
Elapsed: 3.847 seconds

Benchmark Two Different Approaches

The most common use of Measure-Command is comparing two implementations of the same operation. Store both results in variables and compute the ratio to understand the relative speed difference.

$data = 1..10000

# Approach A: ForEach-Object cmdlet
$timeA = Measure-Command {
    $result = $data | ForEach-Object { $_ * 2 }
}

# Approach B: .ForEach() method
$timeB = Measure-Command {
    $result = $data.ForEach({ $_ * 2 })
}

Write-Host "ForEach-Object: $($timeA.TotalMilliseconds) ms"
Write-Host ".ForEach() method: $($timeB.TotalMilliseconds) ms"
Write-Host "Ratio: $([math]::Round($timeA.TotalMilliseconds / $timeB.TotalMilliseconds, 2))x"

Run Multiple Iterations for Accuracy

A single measurement is unreliable due to background process noise, disk cache state, and JIT compilation. Run five or more iterations and average the results. The first run is often slower due to module loading and .NET JIT warm-up — exclude it or run a throwaway warm-up call first.

# Warm-up run to load modules and JIT
Measure-Command { Get-Process } | Out-Null

# Timed runs
$times = 1..5 | ForEach-Object {
    (Measure-Command { Get-Process | Where-Object CPU -gt 10 }).TotalMilliseconds
}

$avg = ($times | Measure-Object -Average).Average
Write-Host "Average over 5 runs: $([math]::Round($avg, 1)) ms"

Identify Pipeline vs Loop Performance

Pipeline streaming and foreach loops have different performance profiles. Pipelines have per-object overhead but constant memory usage. Loops loading all objects first are faster for small collections but consume more memory. Measure both in the context of your actual data size.

$files = Get-ChildItem C:\Logs -Recurse -File

# Pipeline approach
$pipelineTime = Measure-Command {
    $large = $files | Where-Object Length -gt 1MB
}

# Foreach loop approach
$loopTime = Measure-Command {
    $large = foreach ($f in $files) {
        if ($f.Length -gt 1MB) { $f }
    }
}

Write-Host "Pipeline: $($pipelineTime.TotalMilliseconds) ms"
Write-Host "Loop:     $($loopTime.TotalMilliseconds) ms"

Profile Large Scripts Section by Section

For scripts with many distinct phases, wrap each section individually to find the slow one. Name the sections clearly so the output tells you exactly where time is spent.

$profile = @{}

$profile['Load AD Users'] = (Measure-Command {
    $users = Get-ADUser -Filter * -Properties Department,Mail
}).TotalSeconds

$profile['Filter Active'] = (Measure-Command {
    $active = $users | Where-Object Enabled -eq $true
}).TotalSeconds

$profile['Export CSV'] = (Measure-Command {
    $active | Export-Csv C:\Reports\users.csv -NoTypeInformation
}).TotalSeconds

$profile.GetEnumerator() | Sort-Object Value -Descending |
    Format-Table Name, @{L='Seconds';E={[math]::Round($_.Value,2)}} -AutoSize

Common Quick Wins

After measuring, common performance improvements include: filtering at the source with -Filter instead of Where-Object; using .Where() method instead of Where-Object for in-memory collections; avoiding repeated identical cmdlet calls inside a loop; and using -AsJob or ForEach-Object -Parallel (PS7) for independent operations. Always measure before and after any optimization to confirm improvement.

Common Errors and Fixes

  • First run slower due to module loading — warm-up run first. The first call to any cmdlet loads its module into the PowerShell session. This adds hundreds of milliseconds to the first measurement. Always run one throwaway iteration before your timed runs by calling Measure-Command { ... } | Out-Null.
  • Measure-Command output is a TimeSpan object — access .TotalSeconds. If you print the raw result, you see a formatted TimeSpan string like 00:00:03.8470000. For numeric comparisons and calculations, use .TotalSeconds, .TotalMilliseconds, or .TotalMinutes to get a Double.

Related Cmdlets / See Also

Wrapping Up

Measure-Command turns performance tuning from guesswork into data-driven decisions. Always warm up, run multiple iterations, and compare results numerically via .TotalMilliseconds. Once you identify the slow section, the optimization is usually straightforward — filter earlier, avoid redundant calls, or switch to method syntax.

Send-Item -To