Rich renders tables, panels, progress, tracebacks, and styled text in a terminal. Its real value is visual hierarchy, not the number of colors. A professional CLI remains understandable in a narrow terminal, without color, and when output is redirected.
Present records in a table
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Tasks")
table.add_column("Name")
table.add_column("Status")
table.add_row("Import data", "complete")
table.add_row("Build report", "pending")
console.print(table)
Keep data creation separate from rendering. The same function can then produce JSON for automation and a table for people. Do not use color as the only signal for failure or success; include a textual label.
For progress, update an existing task instead of printing one line per item. If Rich formats logs, configure the handler deliberately and preserve fields required for observability. Never print tokens, passwords, or complete responses merely because the formatted traceback is convenient.
Rich detects many terminal capabilities, but tests should cover NO_COLOR, narrow widths, and non-interactive output. For a complete command interface, the Typer CLI guide shows how to separate arguments, validation, and presentation.
The official Rich documentation, accessed July 22, 2026, covers Console, Table, Progress, Logging, and output capture or export.
Install Rich and define one console
Install the package in the environment used by the application:
python -m pip install rich
Create the Console close to the presentation boundary and pass it to functions that need to render. A single configured instance keeps width, color policy, error output, and recording behavior consistent. Library code should usually return values instead of printing them. The command layer can decide whether those values become a Rich table, plain text, or JSON.
from rich.console import Console
def show_result(result: dict[str, str], console: Console) -> None:
console.print(f"[bold]Job:[/bold] {result['name']}")
console.print(f"Status: {result['status']}")
console = Console(stderr=False)
show_result({"name": "daily-import", "status": "complete"}, console)
Rich markup is convenient for trusted strings, but user supplied text may contain brackets that are interpreted as markup. Pass markup=False, use Text, or escape the value with rich.markup.escape. This distinction matters for filenames, exception messages, and content received from an API.
Design tables for narrow terminals
A table is useful when readers compare multiple records across the same fields. It is a poor choice for a single record with twenty properties. Select only actionable columns, order them by importance, and let less important text wrap or disappear in a compact mode.
from rich.table import Table
def task_table(rows: list[dict[str, str]]) -> Table:
table = Table(title="Tasks", show_lines=False)
table.add_column("ID", no_wrap=True)
table.add_column("Task", overflow="fold")
table.add_column("Status", no_wrap=True)
for row in rows:
table.add_row(row["id"], row["name"], row["status"])
return table
Test the result with a fixed width instead of relying only on your development terminal:
from io import StringIO
from rich.console import Console
output = StringIO()
test_console = Console(file=output, width=40, color_system=None)
test_console.print(task_table([
{"id": "42", "name": "Import customer records", "status": "pending"}
]))
assert "Import customer" in output.getvalue()
That test checks meaningful content, not every border character. Snapshot tests of the complete rendering can be useful, but they tend to change when Rich adjusts formatting. Assert critical labels separately so cosmetic updates do not obscure real regressions.
Report progress without breaking automation
Progress is intended for work whose completion can be measured. Update a task after an item succeeds, and place error details in a normal log or final summary. Avoid advancing before the operation finishes because the display would claim work that did not happen.
from rich.progress import Progress
items = ["customers.csv", "orders.csv", "products.csv"]
with Progress() as progress:
task_id = progress.add_task("Importing", total=len(items))
for path in items:
import_file(path)
progress.advance(task_id)
An indeterminate spinner is more honest when the total is unknown. In continuous integration, redirected output, or a machine readable mode, animation may add noise. Make the output mode explicit with an option such as --format json and disable progress for that path. Do not attempt to parse a colorful human interface in a shell script.
Use status, panels, and syntax selectively
console.status() works for a short operation with no useful percentage. Panel can separate an important summary, while Syntax can display a source excerpt. These components should clarify the next action. Wrapping every line in a box consumes space and weakens hierarchy.
For messages, adopt a small vocabulary such as success, warning, and error, each with a textual prefix. Keep the same meaning across commands. Color can reinforce that meaning, but a screen reader, monochrome terminal, or copied log must still communicate it.
from rich.text import Text
message = Text()
message.append("ERROR: ", style="bold red")
message.append("configuration file was not found")
console.print(message)
When showing code or structured data, redact credentials before rendering. Pretty printing makes nested values easy to inspect, which also makes accidental disclosure more likely. Build an allowlist of safe fields rather than trying to recognize every possible secret name.
Integrate Rich with logging
RichHandler improves local log readability and tracebacks, but it does not replace a logging design. Configure levels in the application entry point, keep messages useful without formatting, and attach operational context through logging fields.
import logging
from rich.logging import RichHandler
logging.basicConfig(
level="INFO",
format="%(message)s",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
)
logger = logging.getLogger("importer")
logger.info("Import started", extra={"job_id": "daily-import"})
Production services often need JSON logs for collection and queries. In that case, reserve Rich for an interactive CLI handler and send structured records to the service handler. Avoid enabling locals in tracebacks on systems that may hold credentials or personal data.
Capture output and test fallback behavior
Console(record=True) can export rendered text or HTML, and Console.capture() can collect a fragment. Use these features for reports only after defining who will consume the result. Terminal output contains presentation decisions that may not make a stable data exchange format.
Cover at least three modes in tests: an interactive terminal with color, plain output with color_system=None, and a narrow width. If the application honors the NO_COLOR convention, verify it at the command boundary. Also check exit codes and stderr: errors should not silently move to stdout because a styled console was introduced.
Practical checklist
Before releasing a Rich based command, confirm that:
- Every status has a textual label, not only a color.
- Tables remain understandable at a realistic narrow width.
- redirected output is stable and does not contain animation frames.
- JSON or another structured mode is available when automation needs data.
- untrusted brackets are escaped or markup is disabled.
- secrets are removed before pretty printing or tracebacks.
- logging destinations and levels remain independent from terminal styling.
Rich works best as a presentation layer with clear limits. When domain functions return ordinary Python values and the command layer owns rendering, a polished terminal does not make the application harder to test, automate, or maintain.
Know when plain output is better
Rich is optional when a command prints one value, participates mainly in pipelines, or runs only inside an automated job. A plain path, identifier, or number is often the most useful contract. Add formatting only when it helps a person compare, scan, or act.
Define separate human and machine interfaces instead of guessing from terminal detection alone. Detection provides a sensible default, while --format text, --format json, and --no-color give callers control. Document which output is stable. Human oriented spacing and labels may evolve; a structured schema needs compatibility discipline.
Also consider redirection separately from color. A user may redirect a readable text report to a file and still want complete rows, while a continuous integration runner may emulate a terminal. Capability detection cannot infer intent.
Finally, measure startup cost for commands intended to run very frequently. Import time rarely matters for a long task, but it is noticeable in shell completion and tiny utilities. Lazy imports can be justified at the command boundary after measurement. They should not complicate the whole application based on an assumption.