Input & UX

Progress Bars and Spinners for Python CLIs

Add Rich progress bars and spinners to Python CLIs for deterministic and indeterminate tasks, with live output and multi-task progress tracking.

Updated

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), then progress.advance(task) as work completes.
  • Unknown total → spinner. Pass total=None; add a SpinnerColumn(). Update the total later if you learn it.
  • Many tasks → many bars. Call add_task more than once; loop until progress.finished.
  • Never print() inside a live Progress. Use progress.console.log(...) (or the same Console you 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.

Anatomy of a progress display: a determinate bar made of a description segment, a bar filled to about 60%, a 60% readout, and an elapsed time of 0:00:12; below it an indeterminate spinner labelled "working…" with the note total=None.

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 advance past total is harmless (it clamps), but a wrong total makes the ETA lie. Set the total accurately up front, or use total=None until 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>=13 and test against the version you ship.