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 — determinate bar and indeterminate spinner Anatomy of a progress display Determinate (known total) description description filled bar (~60%) 60% percent 0:00:12 elapsed Indeterminate (unknown total) working… 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.

Several tasks under one Progress A single Rich progress display managing three concurrent tasks, each with its own description, total and completion count. Several tasks under one Progress download ████████░░ 78% 312/400 files add_task returns an id; update() advances that row. verify ███░░░░░░░ 31% 124/400 files Rows redraw together, so the display never tears. index ░░░░░░░░░░ 0% waiting A task can be added before there is anything to do. one Progress context manager owns all three rows One Progress instance is the rule: two live displays fight over the same cursor.
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.

Should the progress bar draw at all? A decision diagram: draw a live progress bar when standard error is a terminal, otherwise print periodic plain-text lines. Should the progress bar draw at all? Is stderr attached to a terminal? Yes — an interactive session Live bar redraws in place No — a pipe, a file, a CI log Plain lines one line per milestone A live bar in a CI log produces thousands of control-character lines nobody can read.

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.

Frequently asked questions

Should a progress bar go to stdout or stderr?

stderr, always. Progress is status, not the result of the command, and a bar written to stdout corrupts the output the moment someone pipes your tool into another. Rich makes this easy: create the progress display on a Console(stderr=True) and keep the result-printing console separate.

Why does my progress bar tear or print thousands of lines?

Two live displays are fighting over the same terminal, or the output is not a terminal at all. Rich can only redraw in place when it owns the cursor, so a second Progress or a bare print() during the render breaks it, and in a CI log — where there is no cursor to move — every redraw becomes another line. Route all output through the same Console and fall back to periodic plain lines when stderr is not a TTY.

What should the total be when I do not know how much work there is?

Set total=None. Rich then renders an indeterminate spinner instead of a percentage, which is honest: a bar that sits at 99% for two minutes is worse than no bar. If you can cheaply discover the total part way through (after listing files, say), call update() with the real total and the display switches to a determinate bar.

How do I show progress for several concurrent tasks?

One Progress instance, several task ids. add_task returns an id per row and update(task_id, advance=1) moves just that row, so the whole display redraws as a unit and never tears. This works with threads and with asyncio; what does not work is one Progress per worker.

Does a progress bar slow the work down?

Only if you update it far too often. Rich throttles redraws, but calling update() once per item on a million-item loop still costs measurable time in call overhead alone. Advance in batches — every hundred items, or on a time interval — and the display stays smooth while the loop stays fast.