TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

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' -Completed

Notes:

  • Single activity: Keep the same -Activity across the entire run. It reads like a headline.
  • Status: Include current/total and 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 -Completed to 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' -Completed

Extract 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' -Completed

Keep 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/finally so -Completed runs 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/

← All Posts Home →