Input & UX

Rendering Tables and JSON with Rich

Build readable CLI tables with Rich - column configuration, semantic styling, narrow terminals, and rendering the same data as JSON for scripts.

Updated

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

The parts of a readable table A Rich table broken into its title, column headers with justification, styled cells and an optional caption. The parts of a readable table Table(title="Environments") sized to the terminal Columns name, style, justify, no_wrap Rows strings, or renderables Styles semantic, never colour alone Caption counts, filters applied Right-justify numbers, left-justify text no_wrap on identifiers people copy Add a word beside every colour Column configuration is where a table stops being a grid and becomes readable.
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

When a table is wider than the terminal Techniques for handling tables that do not fit the available width, and the approaches that make output unreadable. When a table is wider than the terminal Works Drop low-value columns at narrow widths Let Rich wrap one designated column Offer --wide to show everything Point at --json for the full record Unreadable Wrapping every column at once Truncating identifiers people need to copy A fixed width wider than most terminals Horizontal scrolling in a log file A table that does not fit is worse than a list; decide what to drop before the terminal does.

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

Table or JSON? A decision diagram: render a table when a person is reading the output and emit JSON when the consumer is another program. Table or JSON? Who is reading this output? A person, at a terminal Table sized, coloured, scannable Another program, or a redirect JSON stable, parseable Both render from the same data structure, so they cannot report different facts.

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.