A table is the most common structured output a CLI produces, and the difference between a readable one and a grid of text is entirely in the column configuration. The second half of the job is making sure the same data can leave as JSON, so nobody has to parse the table.
TL;DR
- Configure columns:
justify,style,no_wrap,overflow— that is where readability lives. - Right-align numbers, left-align text, never wrap identifiers people copy.
- Pair every colour with a word; colour alone disappears in a log file.
- Render the table and the JSON from the same data structure so they cannot disagree.
- Decide what to drop when the terminal is narrow, before the terminal decides for you.
Prerequisites
Rich installed, and the two-console arrangement from working with stdin, stdout and pipes: results on stdout, narration on stderr.
A table worth reading
from rich.table import Table
from mytool.console import out
def render_environments(rows: list[Environment]) -> None:
table = Table(title="Environments", title_justify="left", caption=f"{len(rows)} total")
table.add_column("Name", style="bold", no_wrap=True)
table.add_column("Region")
table.add_column("Replicas", justify="right")
table.add_column("Updated", justify="right", style="dim")
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, str(env.replicas), env.updated_ago, status)
out.print(table)
Four decisions in the column definitions do most of the work.
justify="right" on numbers lines up the digits, so a column of replica counts can be scanned
rather than read.
no_wrap=True on identifiers stops a name people need to copy from being split across two
lines. It is the single most useful column option, and the one most often missing.
style="dim" on secondary columns lets the eye skip them. Timestamps and metadata are usually
context rather than content.
A caption with the count answers "is this everything" without another command. Add the filter
too when one is applied: 12 of 48 (filtered by --region eu-west-1).
Colour that survives being redirected
The status column above uses [green] and [red], and it pairs each with a word. That pairing is
the important part: a colour-blind reader and a log file both lose the colour, and a row that means
"degraded" only by being red means nothing to either.
The rule that follows: colour is emphasis, never information. Anything a user must know has to be readable in plain text — which is also what makes the output snapshot-testable.
Rich turns colour off automatically when stdout is not a terminal, and honours NO_COLOR. What it
cannot decide is your width policy, which is worth setting explicitly so redirected output is
stable:
out = Console(width=None if sys.stdout.isatty() else 100)
When the table does not fit
Six columns of real data will not fit an 80-column terminal, and Rich's default response — wrapping everything — produces something worse than a list.
Decide the policy yourself:
def render_environments(rows: list[Environment], *, wide: bool = False) -> None:
width = out.size.width
table = Table()
table.add_column("Name", no_wrap=True)
table.add_column("Status", justify="right")
if wide or width >= 100:
table.add_column("Region")
table.add_column("Replicas", justify="right")
if wide or width >= 140:
table.add_column("Updated", justify="right", style="dim")
...
Three columns always, five on a normal terminal, everything under --wide. And when someone needs
every field regardless of width, --json is the honest answer — say so in the caption or the help
text.
The same data, two renderings
The mistake that produces contradictory output is querying twice. Build the data once and render it two ways:
@app.command()
def status(
json_output: Annotated[bool, typer.Option("--json", help="Emit JSON on stdout.")] = False,
wide: Annotated[bool, typer.Option(help="Show every column.")] = False,
) -> None:
rows = core.collect_status() # one query, one list of dataclasses
if json_output:
Envelope(data=[row.as_dict() for row in rows]).emit()
return
render_environments(rows, wide=wide)
Because both branches start from rows, the table and the JSON cannot report different facts. The
JSON should also carry the fields the table only implies — the raw timestamp behind "3 hours ago",
the boolean behind the coloured word — because a script cannot see that a row was red.
Rich can pretty-print JSON for a human while keeping it valid:
out.print_json(data=payload) # coloured and indented on a terminal, plain when piped
Other renderables worth knowing
Panels for a single important thing — a summary at the end of a run, a warning that deserves to stand apart. Not for lists; a panel per item is noise.
Columns for short items that would waste space as a table: a list of thirty environment names laid out in columns is far easier to scan than thirty lines.
Trees for hierarchy — a command tree, a dependency graph, a directory listing. rich.tree.Tree
handles the drawing characters and degrades to ASCII when the encoding cannot represent them.
All three take the same styling and all three degrade the same way when output is redirected, which is the property that makes them safe to use in a tool that gets piped.
Formatting the values inside cells
A table's readability depends as much on the values as on the columns. Four small formatting decisions come up in almost every CLI table.
Relative times for recency, absolute times for records. "3 hours ago" is what a human wants when
scanning; an ISO timestamp is what they want when correlating with a log. Show the relative form in
the table and the absolute one in the JSON, and put the absolute value in a --wide column when the
audience needs both.
Human-readable sizes, machine-readable in JSON. 1.4 GB in the table, 1503238553 in the
payload. A table of raw byte counts is arithmetic homework.
Truncate identifiers deliberately. A commit hash or a UUID rendered in full eats a third of the
width. Show the first eight characters with no_wrap=True, and keep the full value in the JSON —
but never truncate something the user is expected to copy and paste back into your tool.
Empty means empty. A missing value should render as — or a dim none, not as None or an
empty cell. None leaking into output is a small thing that makes a tool look unfinished, and it is
usually a formatting function that forgot the case.
def cell(value: object) -> str:
if value is None or value == "":
return "[dim]—[/]"
if isinstance(value, bool):
return "yes" if value else "no"
return str(value)
Ten lines, applied at the point rows are built, and every table in the tool gains consistent treatment of the awkward cases.
Testing the behaviour
Table output is testable if you fix the width and disable colour:
from rich.console import Console
def render_to_text(rows, width=100) -> str:
console = Console(width=width, no_color=True, file=io.StringIO())
render_environments(rows, console=console)
return console.file.getvalue()
def test_table_shows_the_important_columns():
text = render_to_text([Environment(name="prod", healthy=False, replicas=3)])
assert "prod" in text
assert "degraded" in text # the word, not just the colour
assert "\x1b[" not in text # no escape sequences at this width
def test_narrow_terminals_drop_secondary_columns():
text = render_to_text(SAMPLE_ROWS, width=70)
assert "Name" in text
assert "Updated" not in text # dropped, not wrapped
The "degraded" in text assertion is the one worth keeping permanently: it fails if someone
replaces the word with a coloured symbol, which is exactly the change that makes output unreadable
in a log.
Conclusion
Configure the columns, pair colour with words, decide what to drop before the terminal does, and render both the table and the JSON from one data structure. That last point is the structural one: two renderings of the same list cannot disagree, and it is what lets you improve the table freely while scripts depend on the JSON.
Frequently asked questions
Should tables have borders?
Rich's default box is fine, and box=None produces a lighter list-like layout that suits small
tables. What to avoid is a heavy box around three rows — the borders then carry more visual weight
than the data.
How do I sort or filter a table?
In the data, before rendering, and expose it as flags (--sort, --filter). Sorting inside the
renderer means the JSON and the table can disagree about order, which is exactly the class of bug
the shared-data-structure rule exists to prevent.
Can I stream rows into a table as they arrive?
Not into a Table, which renders once it is complete. For streaming output, print rows as they
arrive in a simple aligned format, or use Live with a table you rebuild — which is heavier and
only worth it for a genuinely live display.
What about very long cell values?
Set overflow="ellipsis" on that column so it truncates visibly rather than wrapping, and make sure
the full value is available in the JSON. A truncated value with no way to get the whole one is a
dead end for the reader.
Do tables work in a Windows terminal?
Yes — Windows Terminal and PowerShell handle the box-drawing characters and colour. The legacy
console may not, and Rich falls back to ASCII boxes; Console(safe_box=True) forces that behaviour
if you need it everywhere.