A CLI that sits silent for thirty seconds looks frozen; users hit Ctrl-C and file bug reports. The fix is feedback: a progress bar when you know how much work there is, a spinner when you don't. Rich's rich.progress.Progress gives you both, plus multiple concurrent task bars and fully custom columns — and it does the hard part (re-rendering a region of the terminal without scrolling) correctly. This guide shows the deterministic case, the indeterminate case, several tasks at once, and the one rule that keeps your display from turning to garbage: route every line of output through the same Console.
TL;DR
- Known total → deterministic bar.
progress.add_task("...", total=N), thenprogress.advance(task)as work completes. - Unknown total → spinner. Pass
total=None; add aSpinnerColumn(). Update the total later if you learn it. - Many tasks → many bars. Call
add_taskmore than once; loop untilprogress.finished. - Never
print()inside a liveProgress. Useprogress.console.log(...)(or the sameConsoleyou passed in) so logs appear above the bar instead of shredding it. - When output is redirected, Rich auto-detects the non-TTY and emits plain refreshes instead of animations.
Deterministic progress: a known total
When you can count the work ahead of time — files to copy, rows to import, URLs to fetch — use a determinate bar. Progress is a context manager: entering it starts a background refresh, exiting it stops cleanly and leaves the final frame on screen.
import time
from rich.progress import Progress
with Progress() as progress:
task = progress.add_task("Downloading...", total=20)
for _ in range(20):
time.sleep(0.001) # stand-in for real work
progress.advance(task) # or progress.update(task, advance=1)
add_task returns a TaskID you pass back to advance/update. The default Progress columns give you a description, a bar, a percentage, and an ETA. advance(task) bumps completion by one; update(task, advance=n) bumps by n and can change other fields at the same time.
For the common "do work over an iterable" case, Rich offers a one-liner that wraps all of the above:
from rich.progress import track
for item in track(range(20), description="Processing..."):
do_work(item)
Reach for track() for a single loop; reach for Progress directly when you need custom columns, multiple bars, or to log alongside the bar.
Indeterminate work: spinners
Sometimes you genuinely don't know the total — you're waiting on a remote server, scanning a directory of unknown size, or blocked on a subprocess. Pass total=None to get an indeterminate task, and add a SpinnerColumn() so there's visible motion. A nice pattern is to start indeterminate and switch to a real bar once the total becomes known.
import time
from rich.progress import Progress, SpinnerColumn, TextColumn
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
transient=True, # erase the spinner once the block exits
) as progress:
task = progress.add_task("Connecting to server...", total=None)
for _ in range(10):
time.sleep(0.001)
progress.update(task) # keeps the spinner alive
# We learned the work size — promote to a determinate bar.
progress.update(task, total=5, completed=0)
for _ in range(5):
time.sleep(0.001)
progress.advance(task)
transient=True removes the progress display when the with block ends, which is ideal for a transient "connecting…" message you don't want cluttering the final output. Drop it (the default False) when you want the completed bar to remain on screen as a record.
Multiple concurrent tasks
Progress can track any number of tasks, each with its own bar, stacked vertically and refreshed together. Add several tasks and advance them as their respective work progresses. progress.finished is True once every determinate task reaches its total — a clean loop condition.
import time
from rich.progress import Progress, BarColumn, TextColumn, TaskProgressColumn
with Progress(
TextColumn("[bold]{task.description}"),
BarColumn(),
TaskProgressColumn(),
) as progress:
alpha = progress.add_task("alpha", total=8)
beta = progress.add_task("beta", total=12)
gamma = progress.add_task("gamma", total=4)
while not progress.finished:
time.sleep(0.001)
progress.advance(alpha, 1)
progress.advance(beta, 2)
progress.advance(gamma, 1)
This is the foundation for things like concurrent downloads: spawn a thread per file, each calling advance on its own task ID. The Progress instance is safe to update from worker threads because the rendering happens on its own refresh thread.
Custom columns
The default layout is fine, but you'll often want to show throughput, an "M of N" counter, or elapsed time. Build the Progress with an explicit list of columns; each is a small renderable that reads from the task.
import time
from rich.progress import (
Progress,
TextColumn,
BarColumn,
MofNCompleteColumn,
TimeElapsedColumn,
)
columns = (
TextColumn("[bold blue]{task.description}"),
BarColumn(bar_width=None), # expand to fill available width
MofNCompleteColumn(), # e.g. "7/10"
TimeElapsedColumn(),
)
with Progress(*columns) as progress:
task = progress.add_task("Processing files", total=10)
for _ in range(10):
time.sleep(0.001)
progress.advance(task)
Useful built-ins include DownloadColumn, TransferSpeedColumn, TimeRemainingColumn, and SpinnerColumn. You can also pass arbitrary fields to add_task(..., foo="bar") and reference them in a TextColumn("{task.fields[foo]}") for fully custom readouts.
The cardinal rule: log through the same Console
This is where most progress-bar code breaks. While Progress is live, it owns a region of the terminal and repaints it on a timer. If you call the built-in print() in that window, your text lands in the middle of the bar and the next repaint smears it across the screen.
The fix: write through the progress display's own console, which knows to scroll your line above the live region.
import time
from rich.progress import Progress, TextColumn, BarColumn
with Progress(TextColumn("{task.description}"), BarColumn()) as progress:
task = progress.add_task("Processing files", total=10)
for i in range(10):
time.sleep(0.001)
progress.console.log(f"handled item {i}") # safe — appears above the bar
progress.advance(task)
If you already created a shared Console (recommended — see Interactive terminal UI with Rich), pass it in with Progress(..., console=my_console) and log through that same object everywhere. The rule generalises: inside a live Rich display, only that display's Console may write to the terminal. Configure your logging handler with Rich's RichHandler bound to the same console and even library log output behaves.
Behaviour when output is redirected
Animations only make sense on a real terminal. When your CLI is piped into a file, grep, or a CI log, you do not want thousands of cursor-control sequences written to disk. Rich detects this automatically via Console.is_terminal: on a non-TTY it disables the live animation and emits a plain, periodic text update instead, so the captured log stays readable.
You can verify and test this without an actual terminal by giving the Console a file buffer:
import io
from rich.console import Console
from rich.progress import Progress, TextColumn, BarColumn
buf = io.StringIO()
console = Console(file=buf, force_terminal=False, width=80)
print("is_terminal:", console.is_terminal) # False -> degraded, plain output
with Progress(TextColumn("{task.description}"), BarColumn(), console=console) as p:
task = p.add_task("export", total=3)
for _ in range(3):
p.advance(task)
assert "export" in buf.getvalue()
This same trick is what makes progress code testable: render into a StringIO, then assert on the captured text. Use force_terminal=True/False to pin the behaviour you want under test or in CI, and reach for Console(record=True) plus console.export_text() when you want a snapshot of the final frame.
Production notes
- Refresh rate.
Progress(refresh_per_second=10)controls repaint frequency. The default is sensible; lower it if many tasks make the terminal flicker, and note Rich throttles repaints regardless so a tight loop won't peg your CPU on rendering. - Threads, not async-by-default. Updates from worker threads are safe. For
asyncio, advance the task from your coroutines; the refresh thread still handles drawing. - Don't over-advance. Calling
advancepasttotalis harmless (it clamps), but a wrongtotalmakes the ETA lie. Set the total accurately up front, or usetotal=Noneuntil you know it. - Windows. Rich works in the modern Windows Terminal and recent PowerShell/conhost. On very old consoles, fall back to plain output — which the non-TTY detection above already handles for redirected streams.
- Pin Rich. Column names and a few defaults have shifted across major versions; pin
rich>=13and test against the version you ship.
Related
- Interactive terminal UI with Rich — the sub-hub: Console, tables, panels, and prompts.
- Advanced Input Parsing for Python CLIs — the parent pillar on input and UX.
- Advanced argument validation strategies — validate the inputs that drive these long-running tasks.