Predictable PowerShell Progress Bars: Write-Progress Done Right for Trustworthy Scripts
Long-running scripts shouldn’t feel like a black box. When you provide precise, predictable progress, people relax, trust the automation, and troubleshoot faster. In PowerShell, Write-Progress is the simplest way to surface that feedback — as long as you use it intentionally: one top-level activity, clear status text, accurate percentage from a known total, and a clean finish state that disappears when done.
Why Predictable Progress Bars Matter
- Trust and transparency: Clear progress reduces anxiety and prevents premature cancellations.
- Actionable feedback: Showing the current item helps pinpoint slow or failing steps.
- Better incident response: When runs are transparent, logs and user reports improve.
- Reliable UX in CI/CD: Stable, predictable output keeps pipelines readable and quiet.
Implement Write-Progress the Right Way
Use a single top-level activity and precise status
Keep your UI simple: one activity name for the whole operation. Update the -Status with the current item and count. Compute -PercentComplete from a known total to avoid infinite bars.
$root = 'C:\Data'
$files = Get-ChildItem -Path $root -File -Recurse
$total = $files.Count
$i = 0
foreach ($f in $files) {
$i++
$pct = if ($total -gt 0) { [int](($i / $total) * 100) } else { 100 }
Write-Progress -Activity 'Processing files' -Status ("{0} of {1}: {2}" -f $i, $total, $f.Name) -PercentComplete $pct
# Work here (simulate)
Start-Sleep -Milliseconds 50
}
Write-Progress -Activity 'Processing files' -CompletedNotes:
- Single activity: Keep the same
-Activityacross the entire run. It reads like a headline. - Status: Include
current/totaland the current item to make progress tangible. - Percent: Cast to
[int]for a clean 0–100 number; round down so you never overshoot 100%. - Completed: Always call
-Completedto clear the bar for clean logs and terminals.
Avoid infinite bars: pre-compute totals
Write-Progress has an indeterminate mode if you omit -PercentComplete; avoid it. For streaming sources where the total isn’t obvious, restructure to compute totals first. For example:
- Files: enumerate once (
Get-ChildItem), then process. - API pages: fetch a count endpoint first, or request page 1 to read
totalCount. - Database rows: run
SELECT COUNT(*)before fetching pages.
If you truly cannot know the total, consider showing the top-level activity as a fixed phase (e.g., “Discovering items…”) and only use percent once you’ve measured the work set.
Make it resilient: always clear progress in a finally block
Ensure the progress bar disappears even on failures. Wrap your run in try/finally and call -Completed in finally:
Write-Progress -Activity 'Processing files' -Status 'Starting...' -PercentComplete 0
try {
$files = Get-ChildItem -Path 'C:\Data' -File -Recurse
$total = $files.Count
$i = 0
foreach ($f in $files) {
$i++
$pct = if ($total -gt 0) { [int](($i / $total) * 100) } else { 100 }
Write-Progress -Activity 'Processing files' -Status ("{0} of {1}: {2}" -f $i, $total, $f.Name) -PercentComplete $pct
# Do work
}
}
finally {
Write-Progress -Activity 'Processing files' -Completed
}Add ETA and throughput (optional but delightful)
ETAs calm users and help you estimate capacity. Use a Stopwatch to compute duration, throughput, and ETA:
$items = Get-ChildItem -Path 'C:\Data' -File -Recurse
$total = $items.Count
$i = 0
$sw = [System.Diagnostics.Stopwatch]::StartNew()
foreach ($it in $items) {
$i++
# Work here
Start-Sleep -Milliseconds 20
$pct = if ($total -gt 0) { [int](($i / $total) * 100) } else { 100 }
$elapsed = $sw.Elapsed
$rate = if ($elapsed.TotalSeconds -gt 0) { [math]::Round($i / $elapsed.TotalSeconds, 2) } else { 0 }
$remaining = if ($total -gt 0 -and $rate -gt 0) { [timespan]::FromSeconds(($total - $i) / $rate) } else { [timespan]::Zero }
$status = "{0}/{1} | {2} | {3} items/s | ETA {4}" -f $i, $total, $it.Name, $rate, ($remaining.ToString())
Write-Progress -Activity 'Processing files' -Status $status -PercentComplete $pct
}
Write-Progress -Activity 'Processing files' -CompletedExtract a tiny helper for consistency
Centralize your style in a helper function so every script is consistent:
function Set-Progress {
param(
[int]$Current,
[int]$Total,
[string]$Activity,
[string]$CurrentItem
)
$pct = if ($Total -gt 0) { [int](($Current / $Total) * 100) } else { 100 }
$status = "{0} of {1}: {2}" -f $Current, $Total, $CurrentItem
Write-Progress -Activity $Activity -Status $status -PercentComplete $pct
}Production Tips: CI/CD, Logging, and Performance
Make CI/CD quiet by disabling progress when non-interactive
Progress is for humans. In non-interactive hosts (like GitHub Actions, Azure Pipelines, or scheduled tasks), progress can spam logs or render poorly. Detect and disable it cleanly:
# Disable progress in CI or non-interactive sessions
if ($env:CI -or -not ($Host.Name -match 'ConsoleHost|Visual Studio Code')) {
$ProgressPreference = 'SilentlyContinue'
}
# Your script here...Alternatively, expose a -NoProgress switch in your scripts:
param([switch]$NoProgress)
if ($NoProgress) { $ProgressPreference = 'SilentlyContinue' }Throttle updates for speed
Progress updates are relatively cheap, but at very high iteration counts they can add up. Update every N items or after a time interval:
$items = 1..50000
$total = $items.Count
$i = 0
$lastUpdate = Get-Date
$updateMs = 100 # refresh at most every 100ms
foreach ($n in $items) {
$i++
# Do work
if ((Get-Date) - $lastUpdate -ge [timespan]::FromMilliseconds($updateMs) -or $i -eq $total) {
$pct = [int](($i / $total) * 100)
Write-Progress -Activity 'Crunching numbers' -Status ("{0}/{1}" -f $i, $total) -PercentComplete $pct
$lastUpdate = Get-Date
}
}
Write-Progress -Activity 'Crunching numbers' -CompletedKeep a single top-level activity across phases
If your workflow has phases (e.g., “Discover”, “Validate”, “Process”), keep the activity name stable and vary the status text so the UI remains predictable:
$phase = 'Discovering items'
Write-Progress -Activity 'User import' -Status $phase -PercentComplete 5
# discover...
$phase = 'Validating records'
Write-Progress -Activity 'User import' -Status $phase -PercentComplete 15
# validate...
$phase = 'Importing users'
# run main loop updating both status and percent...Handle errors gracefully and summarize
- Use
try/catch/finallyso-Completedruns even on error. - Write one-line summaries to the console or log with counts and duration.
- Keep status lines short (aim for under 80 chars) to avoid wrapping.
$sw = [System.Diagnostics.Stopwatch]::StartNew()
[int]$ok = 0; [int]$fail = 0
try {
# ... processing with Set-Progress/Write-Progress ...
}
catch {
$fail++
}
finally {
Write-Progress -Activity 'User import' -Completed
$sw.Stop()
Write-Host ("User import complete: {0} ok, {1} failed in {2}" -f $ok, $fail, $sw.Elapsed)
}Avoid per-thread progress in parallel loops
Write-Progress is host-driven UI; using it from multiple runspaces or ForEach-Object -Parallel will interleave output. Prefer a controller pattern: workers increment a shared counter, and the main thread periodically updates a single progress bar.
- Use
[System.Collections.Concurrent.ConcurrentQueue]or[System.Threading.Interlocked]::Increment()for thread-safe counters. - Update progress from the main runspace only.
Security and reliability notes
- Don’t log secrets in status lines. If you show current items, mask or omit sensitive values.
- Be deterministic: compute totals before mutating items so the denominator doesn’t change mid-run.
- Unicode-safe: Truncate or sanitize very long filenames to keep status short.
What you get
- Clear feedback and calmer users.
- Predictable runs that are easy to troubleshoot.
- Cleaner terminals and logs thanks to
-Completed.
Design friendlier automations in PowerShell with predictable progress bars. For deeper patterns, advanced scripting techniques, and production-ready practices, check out the PowerShell Advanced Cookbook: https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/