Input & UX

Interactive Terminal UI with Rich

Build interactive Python CLI interfaces with the Rich library — tables, progress bars, panels, prompts, and live-updating terminal output.

Updated

Rich turns a plain Python CLI into something readable: coloured text, aligned tables, bordered panels, progress bars, and prompts — all from one library with no terminal-escape-code wrangling on your part. This overview surveys the pieces Rich gives you for building a friendly terminal UI, and points you to the deeper guides for each. It is aimed at anyone who has a working CLI and wants the output to stop looking like a 1990s log dump.

TL;DR

  • Everything starts with a Console. Create one, call console.print(...), and you get markup, colour, and word-wrapping for free.
  • Rich ships ready-made building blocks: Table, Panel, styled text, Prompt/Confirm, and Progress for progress bars and spinners.
  • Rich detects when output is not a terminal (piped, redirected, in CI) and degrades gracefully — it drops control codes and respects NO_COLOR so your tool stays scriptable.
The Rich toolkit — one Console drives every component The Rich toolkit Rich Console Tables Panels Progress Prompts Styled text / markup Live display degrades gracefully when piped (NO_COLOR / not a TTY)

The Console: one object for all output

The Console is the entry point for everything Rich draws. Replace print() with console.print() and you immediately get console markup, automatic word-wrap to the terminal width, and theme-aware colour.

One Console, two destinations A single Rich console instance writes results to standard output and a second console writes status and errors to standard error. One Console, two destinations Console() results — stdout Console(stderr=True) status, warnings, errors and Two Console objects, created once at import: everything else in the program prints through one of them.
from rich.console import Console

console = Console()
console.print("[bold green]✓ deploy succeeded[/]  in 4.2s")
console.print({"region": "eu-west-1", "replicas": 3})  # pretty-prints structures

Create the Console once and pass it around (or stash it on a small app object) rather than constructing a new one per call. That single instance is also what keeps progress bars, prompts, and log lines from fighting over the cursor — a theme that comes up again on the progress bars and spinners page.

Tables and panels for structured output

When your command returns rows — servers, files, test results — reach for Table. For a framed summary or a callout, use Panel.

from rich.table import Table
from rich.panel import Panel

table = Table(title="Servers")
table.add_column("Host")
table.add_column("Status", style="green")
table.add_row("web-01", "up")
table.add_row("web-02", "down")
console.print(table)

console.print(Panel("All systems nominal", title="Status"))

Tables handle column widths, alignment, and wrapping automatically, so you never hand-pad strings again.

Styled text and prompts

Rich markup ([bold], [red], [link=…]) inlines styling without escape codes. For interactive input, rich.prompt gives you typed, validated prompts:

from rich.prompt import Prompt, Confirm

name = Prompt.ask("Project name", default="my-cli")
if Confirm.ask("Initialise a git repo?", default=True):
    ...

Prompt.ask supports choices=[...], default values, and password masking. It is a quick win for interactive flows, though for anything driven by flags or files you will still lean on argument parsing and config files and env vars.

Progress, spinners, and live output

Long-running work deserves feedback. Rich's Progress covers deterministic tasks (a known total — copying N files), indeterminate work (a spinner while you wait on a network call), and several concurrent task bars at once. Live lets you re-render a region of the screen in place for dashboards. This is the single biggest UX upgrade for most CLIs, so it has its own deep dive:

Degrade gracefully when output is not a terminal

This is the rule that separates a polished CLI from a broken one: your tool will be piped into grep, redirected to a file, and run in CI. Rich handles this for you, but only if you let it own the output.

What changes when output is not a terminal A comparison of Rich output behaviour when writing to an interactive terminal versus a pipe or a file. What changes when output is not a terminal Feature Terminal Piped or redirected Colour on off automatically Progress bars live, redrawn should be suppressed Table width fits the window a fixed width Spinners animated nothing at all Rich detects most of this for you; the part it cannot guess is whether a progress bar belongs in a log file.

Console auto-detects whether it is attached to a TTY via console.is_terminal. When it is not (a pipe, a file, a CI log), Rich suppresses animations and colour and emits plain text, and it honours the NO_COLOR environment variable. Check is_terminal yourself when you want an explicit fallback:

if console.is_terminal:
    console.print(table)          # full styled output
else:
    for row in rows:
        console.print("\t".join(row))  # script-friendly plain text

Set force_terminal=True/False on the Console to override detection in tests or CI, and use Console(record=True) to capture rendered output for regression tests.

Building a real output surface

A Rich Console is the single object every piece of output should go through. Two of them, actually — one for results and one for narration — created once and imported everywhere:

# src/mytool/console.py
from rich.console import Console

out = Console()                 # results: stdout, pipeable
err = Console(stderr=True)      # progress, warnings, errors

From there, the three constructs that cover most CLI output are tables, panels and styled text.

from rich.table import Table

def render_environments(rows: list[Environment]) -> None:
    table = Table(title="Environments", title_justify="left")
    table.add_column("Name", style="bold")
    table.add_column("Region")
    table.add_column("Status", justify="right")

    for env in rows:
        status = "[green]healthy[/]" if env.healthy else "[red]degraded[/]"
        table.add_row(env.name, env.region, status)

    out.print(table)

Rich measures the terminal and sizes the columns, so nothing is truncated on a wide screen or wrapped into unreadable mush on a narrow one. When output is not a terminal it falls back to a fixed width and drops the colour, which is exactly the behaviour you want when someone redirects to a file.

Panels are for one important thing rather than a list of things — a summary at the end of a run, or a warning that deserves to stand apart:

from rich.panel import Panel

err.print(Panel("3 files failed to upload. Re-run with --debug for details.",
                title="Partial failure", border_style="yellow"))

And markup covers the rest. [bold], [dim], [red] and friends are inline and composable, which means you never build ANSI escapes by hand and never leave one unterminated.

Prompts, and when not to use them

Rich's prompts are pleasant, and they are the single easiest way to make a tool unusable in automation.

from rich.prompt import Confirm, Prompt

name = Prompt.ask("Environment name", default="staging")
if Confirm.ask("Deploy now?", default=False):
    ...

The rule is to check first whether anyone is there to answer:

import sys

def confirm(question: str, *, assume_yes: bool) -> bool:
    if assume_yes:
        return True
    if not sys.stdin.isatty():
        err.print("[red]refusing to prompt without a terminal — pass --yes[/]")
        raise typer.Exit(2)
    return Confirm.ask(question, default=False)

Three behaviours, one function: an explicit --yes skips the question, a non-interactive session fails immediately with a message naming the flag, and an interactive user gets the prompt. A tool that prompts unconditionally hangs a CI job until the pipeline times out, and the log gives no clue why.

The same reasoning applies to any input Rich can gather — passwords, selections, confirmations. Interactive is a nice path, never the only path.

Behaving well when nobody is watching

Terminal output has three modes, and a well-behaved CLI notices which one it is in.

Interactive. Colour, live progress, prompts, tables sized to the window. This is the mode everyone develops in and the only one most tools are tested in.

Piped or redirected. Colour off, no live redraw, stable widths. Rich handles most of this automatically because it checks whether the stream is a terminal — but it cannot know that your progress bar should become periodic plain lines rather than nothing at all, so that decision is yours.

CI. No terminal, often a log aggregator downstream, and frequently a NO_COLOR or CI environment variable to tell you so. Honour NO_COLOR — it is a one-line check and it makes your tool a good citizen in every log file it lands in:

import os

no_color = bool(os.environ.get("NO_COLOR")) or not sys.stderr.isatty()
err = Console(stderr=True, no_color=no_color)

The most common failure here is not ugliness, it is corruption: a progress bar or a status line written to stdout ends up inside the JSON someone was piping into jq. Keeping results on one stream and narration on the other makes that structurally impossible, which is worth more than any amount of styling.

Structured output for machines

Every tool that prints a table eventually meets someone who wants to parse it. Give them a real format instead, and the table stops being an interface you have to keep stable.

@app.command()
def status(
    json_output: Annotated[bool, typer.Option("--json", help="Emit JSON on stdout.")] = False,
) -> None:
    rows = core.collect_status()
    if json_output:
        typer.echo(json.dumps([row.as_dict() for row in rows]))
        return
    render_environments(rows)

Three properties make a --json mode worth having. It goes to stdout and nothing else does, so mytool status --json | jq '.[] | select(.healthy == false)' works. Its shape is stable — adding a key is fine, renaming or removing one is a breaking change like any other. And it is complete: the JSON should carry everything the table shows, including the fields you only render as colour, because a script cannot see that a row was red.

The same envelope should be used by every command that has one. A tool where status --json returns a list and list --json returns {"items": [...]} is a tool people write per-command parsing for. Pick one shape and repeat it.

Rich can print the JSON prettily for humans while keeping it valid:

out.print_json(data=payload)     # coloured and indented on a terminal, plain when piped

Live displays without the tearing

Live is the mechanism behind progress bars, status spinners and refreshing tables. The rule that prevents almost every problem: one live display at a time, owning all output while it runs.

from rich.live import Live
from rich.spinner import Spinner

with Live(Spinner("dots", text="Resolving dependencies…"), console=err, transient=True):
    graph = core.resolve()
err.print(f"Resolved {len(graph)} packages")

transient=True erases the spinner when the block exits, which is what you want for a step that has finished — the final state gets its own line rather than leaving a dead spinner on screen.

If you must print while a live display is running, print through it. Both Live and Progress expose the console they own, and console.print from inside the block is interleaved correctly. A bare print() writes straight to the file descriptor, and the display redraws over it.

For anything longer than a couple of seconds, prefer progress with a total over an indeterminate spinner, and prefer a spinner over silence. The progress bars guide covers concurrent tasks, custom columns and the redirect behaviour in detail.

A house style worth adopting

Consistency does more for perceived quality than any individual widget. Four conventions cover most of it.

One accent colour, used for one thing. Pick a colour for "this is the thing you asked about" and do not use it for anything else. Two accent colours read as decoration; one reads as structure.

Semantic colours only where they carry meaning. Red for failure, yellow for a warning, green for success, dim for detail. Everything else stays default — a terminal already has a foreground colour chosen by the user, and overriding it wholesale is how tools become unreadable on light backgrounds.

Never rely on colour alone. A red row and a green row look identical to a colour-blind reader and to a log file. Pair the colour with a word (failed, ok) or a symbol, so the information survives the loss of styling.

Quiet by default. Print the result and nothing else on a successful run. Progress and detail belong behind -v, and a tool that prints six lines of ceremony for a one-line answer is a tool people wrap in >/dev/null.

Rendering errors people can act on

The output surface that matters most is the one shown when something fails, and it is usually the least designed. A useful failure has three parts: what happened, in which input, and what to do next.

def report_config_error(exc: ConfigError) -> None:
    err.print(f"[red]config error[/] in [bold]{exc.path}[/] (line {exc.line})")
    err.print(f"  {exc.message}")
    if exc.suggestion:
        err.print(f"  [dim]did you mean[/] {exc.suggestion}[dim]?[/]")

That renders as three short lines rather than a wall of traceback, and every line is doing work: the first locates the problem, the second explains it, the third offers the fix. Colour is used once, for the word that says what kind of message this is.

Two details make the difference between this being helpful and being noise. Send it to stderr, so a user who is capturing results still sees it and a script capturing results does not ingest it. And keep the traceback available behind --debug rather than deleting it — the information is genuinely useful to you, just not to the person who mistyped a key.

For a failure that affects some items but not all, summarise rather than repeating:

err.print(f"[yellow]{len(failures)} of {total} files failed[/] — first: {failures[0].name}")
err.print("[dim]re-run with --verbose to list them all[/]")

Printing two hundred failure lines buries the one piece of information the reader needs, which is how bad it was and whether to retry.

Frequently asked questions

Does Rich slow a CLI down?

Importing it costs roughly 30–60 ms, which is significant against a 20 ms interpreter floor but small against most real work. If --help needs to stay under 100 ms, import Rich inside the functions that render output rather than at module level — the same lazy-import discipline that applies to any heavy dependency.

Should I use Rich markup in log messages?

No. Log records go through formatters and may end up as JSON, in a file, or in an aggregator that will faithfully preserve [bold] as literal text. Keep markup in the presentation layer, and if you want colourful logs on a developer's terminal, attach Rich's own RichHandler to the logging handler instead — the colour is then applied at render time by the handler that knows it is writing to a terminal.

How do I test coloured output?

Construct a Console with force_terminal=False and width=80 in tests, or capture with console.capture(), so the assertions run against plain text at a fixed width. Asserting on escape sequences is brittle and tells you nothing useful; asserting that the word "degraded" appears in the right column does.

Can I show a table and a progress bar at the same time?

Only through one Live display. Two live regions fight over the cursor and the output tears. Rich's Live accepts a renderable that can be a Table, a Group or a layout, and you update that object rather than printing separately — but for most CLIs the simpler answer is to show progress while working and print the table when the work is done.

What if my users are on a terminal that mangles Unicode?

Rich degrades box-drawing characters and emoji when the encoding cannot represent them, so most of it is handled. For the remainder, Console(safe_box=True) forces ASCII box characters. The practical safeguard is running the test suite on Windows in CI rather than special-casing anything in the code.

Is Rich a sensible dependency for a small tool?

For anything that prints a table, formats an error, or shows progress, yes — it replaces a surprising amount of hand-rolled width arithmetic and escape-sequence handling, and it degrades correctly when output is redirected. For a tool that prints one line and exits, it is weight you do not need; typer.echo and a plain format string are the right size for that job. The deciding question is whether you would otherwise write terminal-width logic yourself.

Where should the Console objects be created?

In one small module that everything imports, created at import time rather than per call. Constructing a Console inspects the terminal, so making a fresh one inside a loop is both wasteful and a source of inconsistency when one of them guesses the width differently. A single pair — results and narration — also gives you exactly one place to apply a --no-color flag.