Better Terminal Output for Python Ops Tools with rich
Tables, progress bars, colored logs, and readable tracebacks. How the rich library turns a wall of print() statements into a CLI your team enjoys using.
- #python
- #bash
- #cli
The internal tools I build live or die on how readable their output is. A kubectl-style status command that dumps a flat wall of print() lines gets ignored; the same data in a clean table gets used. For a long time I wrote my own ANSI escape codes and column-padding logic to bridge that gap, which was miserable. Then I found rich, and I deleted most of that code.
rich is a Python library for beautiful terminal output: tables, progress bars, syntax highlighting, colored logging, and tracebacks you can actually read. Here is how I use it in real ops tooling, and where an AI assistant fits into building it out.
Install and the Console object
pip install rich
Everything starts with a Console. Think of it as a smarter print that understands markup, colors, and terminal width:
from rich.console import Console
console = Console()
console.print("[bold green]Deploy succeeded[/bold green]")
console.print("[yellow]warning:[/] disk at 82%")
The [bold green]...[/bold green] markup is rendered as styling on a real terminal and stripped automatically when output is piped to a file or a non-TTY. That last part matters: your logs stay clean when redirected.
Tables that format themselves
This is the feature I reach for constantly. Building a table is declarative, and column widths sort themselves out:
from rich.table import Table
from rich.console import Console
console = Console()
table = Table(title="Pod Status")
table.add_column("Name", style="cyan")
table.add_column("Ready", justify="center")
table.add_column("Restarts", justify="right", style="magenta")
table.add_row("api-7f9", "✓", "0")
table.add_row("worker-2a1", "✗", "14")
console.print(table)
No manual padding, no guessing column widths, no breaking when a value is longer than expected. For status dashboards inside a CLI this alone justifies the dependency.
Progress bars for long operations
When a script processes a few hundred items or downloads a batch of files, a progress bar turns “is this hung?” into visible, reassuring motion:
from rich.progress import track
import time
for host in track(hosts, description="Checking hosts..."):
check_host(host)
track() wraps any iterable and renders a live bar with a count and ETA. For more control — multiple concurrent bars, custom columns — there is a full Progress API, but track() covers most needs.
Pro Tip: rich automatically suppresses animations when output is not a TTY, so a progress bar in a cron job logs a single clean summary line instead of thousands of redraw escape sequences. You get a nice interactive experience without garbage in your log files.
Readable tracebacks
When an ops script crashes, the default Python traceback is a dense wall of frames. rich can render tracebacks with syntax highlighting and local variables, which cuts debugging time noticeably:
from rich.traceback import install
install(show_locals=True)
Call that once at startup and every uncaught exception gets the pretty treatment. The show_locals=True option prints the local variables at each frame — invaluable when a script fails on input you cannot reproduce.
Be careful with show_locals=True in anything that handles secrets, though: it will happily print the value of a variable holding a token. I leave it off in production paths and only flip it on for local debugging.
Colored, structured logging
rich ships a logging handler that gives you timestamps, level colors, and clickable file links for free:
import logging
from rich.logging import RichHandler
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
handlers=[RichHandler(rich_tracebacks=True)],
)
log = logging.getLogger("deploytool")
log.info("Starting deploy")
log.warning("Retrying connection")
log.error("Deploy failed")
Because it is a standard logging handler, you can keep a plain handler for file output and use RichHandler only for the console. Your files stay grep-able while humans get color.
Live status displays with Live and Status
For commands that do work in phases, a spinner or a continuously-updating panel beats printing a new line every second. Status gives you a spinner with a message you can change as the work moves:
from rich.console import Console
import time
console = Console()
with console.status("[bold green]Connecting...") as status:
time.sleep(1)
status.update("[bold green]Fetching inventory...")
time.sleep(1)
status.update("[bold green]Validating...")
time.sleep(1)
console.print("[green]Done[/green]")
The spinner animates while the block runs and disappears cleanly when it exits. For a fully custom dashboard that redraws a table in place — say, live pod status that refreshes every few seconds — rich.live.Live lets you replace a renderable repeatedly without scrolling the terminal. I use it for “watch”-style commands that would otherwise spam the scrollback.
Panels and rules for structure
Long output is easier to scan when it is visually grouped. Panel boxes a block of content with an optional title, and rule draws a labeled horizontal divider:
from rich.console import Console
from rich.panel import Panel
console = Console()
console.rule("[bold]Pre-flight checks")
console.print(Panel("All 12 nodes reachable\nDisk headroom: 38%",
title="Cluster", border_style="green"))
console.rule("[bold]Deploy")
These are small touches, but in a tool teammates run dozens of times a day, the structure is what makes output skimmable instead of a scroll-back wall.
Keeping it honest in pipelines
The one rule I follow: never let pretty output change the data your tool emits when machines are consuming it. If a downstream script parses your output, give it a --json flag that bypasses rich entirely:
import json
if args.json:
print(json.dumps(results))
else:
console.print(build_table(results))
Pretty output is for humans; structured output is for pipes. Supporting both keeps your tool friendly and scriptable.
Letting AI build the boilerplate
Wiring up tables, columns, and progress bars is repetitive, and an AI assistant is genuinely good at it. I will describe my data shape to Claude or Cursor and ask for a rich.Table with the right columns and justification, or a track() loop around an existing for-loop.
It is a fast junior engineer for this kind of formatting work, but I still review what it produces before it ships, because output code can leak data. Specifically I check:
- That
show_locals=Trueis not enabled on any path that touches secrets. - That a
--json/non-TTY path exists so the tool stays scriptable. - That no real credentials were pasted into the prompt to “show the data” — I hand the AI a fake row, never a real token.
I keep those notes in the prompt workspace and route output-heavy tools through the code review dashboard before release.
Conclusion
rich replaces hand-rolled ANSI codes and column math with declarative tables, drop-in progress bars, colored logging, and readable tracebacks — and it gracefully degrades to clean output when piped. Keep a structured --json path for machines, keep show_locals away from secrets, and let an AI assistant draft the formatting while you review the parts that handle real data. Your internal tools will go from ignored to genuinely pleasant.
More in the Bash and Python automation category. Reusable starters are in the prompt library, and curated sets are in the prompt packs.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.