Input & UX

Adding a Format Flag for Table, JSON and CSV Output

Add one --output option to a Typer or Click CLI that renders the same records as a table, JSON, JSON Lines or CSV, with a TTY-aware default and tests.

Updated

Your list command prints a nice table, and now a colleague wants to pipe it into jq. The quick fix — an if json: branch inside the command — works once and then spreads: the next command copies it, the third command adds --csv, and within a release the tool has five commands with three different ideas about how output works. This guide builds the alternative from scratch: one --output/-o option shared by every listing command, a renderer per format, and a default that does the right thing in a terminal and in a pipe. It is the hands-on recipe behind the output formats topic.

Prerequisites

  • Python 3.10+ with Typer (uv add typer) or Click 8; Rich comes with Typer and draws the table.
  • A command that already produces data as a list of dictionaries, or one you are willing to refactor so it does.
  • Basic familiarity with emitting JSON output for scripting, which covers what makes JSON output script-friendly.

Step 1: separate the data from the printing

The refactor that makes everything else possible is small. A command should return records — plain dictionaries with raw values — and something else should decide how to print them. Values stay raw: an ISO timestamp rather than "2 hours ago", an integer byte count rather than "1.2 GB". Humanising happens only in the table.

Formatting inside commands vs a shared layer A comparison of each command formatting its own output with commands returning records to a shared renderer. Formatting inside commands vs a shared layer Concern if json: in each command Records + renderers Add a new format edit every command one function Flag names --json, --csv, --raw… one -o option Same keys everywhere by luck by construction Testing per command, per branch renderers once The refactor is small: return the rows, and let one place print them.
# src/fleet/data.py
from datetime import datetime, timezone

def fetch_servers() -> list[dict]:
    """Stand-in for an API call. Returns raw, JSON-serialisable values."""
    now = datetime(2026, 10, 2, 12, 0, tzinfo=timezone.utc).isoformat()
    return [
        {"name": "web-1", "region": "eu-west", "cpu": 0.42, "status": "ok", "seen": now},
        {"name": "web-2", "region": "eu-west", "cpu": 0.91, "status": "hot", "seen": now},
        {"name": "db-1", "region": "us-east", "cpu": 0.18, "status": "ok", "seen": now},
    ]

SERVER_COLUMNS = ["name", "region", "cpu", "status", "seen"]

The column list matters as much as the rows. It fixes the order of CSV columns and table headers, and it defines which keys appear in JSON, so a stray internal key in a record never leaks into the output.

Step 2: one renderer per format

Each renderer takes the same two arguments and returns a string. A closed Enum names the formats, and a dictionary maps each member to its function:

# src/fleet/output.py
from __future__ import annotations

import csv
import io
import json
import os
import shutil
import sys
from collections.abc import Callable, Sequence
from enum import Enum
from typing import Any

Row = dict[str, Any]


class Format(str, Enum):
    table = "table"
    json = "json"
    jsonl = "jsonl"
    csv = "csv"


def _table(rows: Sequence[Row], columns: Sequence[str]) -> str:
    from rich.console import Console
    from rich.table import Table

    table = Table(*columns, box=None, pad_edge=False, header_style="bold")
    for row in rows:
        table.add_row(*("" if row.get(c) is None else str(row[c]) for c in columns))
    buf = io.StringIO()
    Console(file=buf, width=shutil.get_terminal_size((120, 24)).columns).print(table)
    return buf.getvalue()


def _json(rows: Sequence[Row], columns: Sequence[str]) -> str:
    return json.dumps([{c: r.get(c) for c in columns} for r in rows], indent=2) + "\n"


def _jsonl(rows: Sequence[Row], columns: Sequence[str]) -> str:
    return "".join(
        json.dumps({c: r.get(c) for c in columns}, separators=(",", ":")) + "\n" for r in rows
    )


def _csv(rows: Sequence[Row], columns: Sequence[str]) -> str:
    buf = io.StringIO(newline="")
    writer = csv.DictWriter(buf, fieldnames=list(columns), extrasaction="ignore",
                            lineterminator="\n")
    writer.writeheader()
    writer.writerows(rows)
    return buf.getvalue()


RENDERERS: dict[Format, Callable[[Sequence[Row], Sequence[str]], str]] = {
    Format.table: _table,
    Format.json: _json,
    Format.jsonl: _jsonl,
    Format.csv: _csv,
}


def resolve_format(explicit: Format | None) -> Format:
    """Flag wins, then FLEET_OUTPUT, then table-on-a-terminal / jsonl-in-a-pipe."""
    if explicit is not None:
        return explicit
    env = os.environ.get("FLEET_OUTPUT")
    if env:
        try:
            return Format(env)
        except ValueError:
            print(f"warning: ignoring FLEET_OUTPUT={env!r}", file=sys.stderr)
    return Format.table if sys.stdout.isatty() else Format.jsonl


def emit(rows: Sequence[Row], columns: Sequence[str], fmt: Format) -> None:
    sys.stdout.write(RENDERERS[fmt](rows, columns))

A few details are deliberate. Rich is imported inside _table, so commands piped into jq never pay its import cost — see reducing CLI dependency weight. JSON Lines uses compact separators because each line is meant for a machine. And the CSV writer sets lineterminator="\n" explicitly; the module default is \r\n, which is correct for RFC 4180 but surprises Unix tools.

Step 3: wire the option into commands

Define the option once and reuse it, so every command spells it the same way and shows the same help:

# src/fleet/cli.py
from typing import Annotated, Optional

import typer

from fleet.data import SERVER_COLUMNS, fetch_servers
from fleet.output import Format, emit, resolve_format

app = typer.Typer()

OutputOpt = Annotated[
    Optional[Format],
    typer.Option("--output", "-o", case_sensitive=False,
                 help="Output format. Default: table on a terminal, jsonl when piped."),
]


@app.callback()
def main() -> None:
    """Manage the fleet."""


@app.command("list")
def list_servers(output: OutputOpt = None) -> None:
    """List servers."""
    emit(fetch_servers(), SERVER_COLUMNS, resolve_format(output))


@app.command()
def regions(output: OutputOpt = None) -> None:
    """List regions with server counts."""
    counts: dict[str, int] = {}
    for s in fetch_servers():
        counts[s["region"]] = counts.get(s["region"], 0) + 1
    rows = [{"region": r, "servers": n} for r, n in sorted(counts.items())]
    emit(rows, ["region", "servers"], resolve_format(output))


if __name__ == "__main__":
    app()

The Annotated alias is the Typer equivalent of a shared decorator; sharing common options across commands covers the pattern in depth. In Click, the same thing is a reusable decorator:

import click

output_option = click.option(
    "--output", "-o",
    type=click.Choice([f.value for f in Format], case_sensitive=False),
    default=None,
    help="Output format. Default: table on a terminal, jsonl when piped.",
)

@click.command("list")
@output_option
def list_servers(output: str | None) -> None:
    emit(fetch_servers(), SERVER_COLUMNS, resolve_format(Format(output) if output else None))

Running it shows the three behaviours side by side:

One command, three formats Terminal session showing the list command printing a table on a terminal, JSON Lines when piped, and CSV when asked. One command, three formats bash $ fleet list name region cpu status web-1 eu-west 0.42 ok $ fleet list | head -1 {"name":"web-1","region":"eu-west","cpu":0.42,...} $ fleet list -o csv | head -2 name,region,cpu,status,seen web-1,eu-west,0.42,ok,2026-10-02T12:00:00+00:00 The pipe gets JSON Lines without a flag; the table only ever reaches a terminal.

UX considerations

  • Name it --output, short -o. That matches kubectl, the AWS and Azure CLIs and many more, so users guess it. If --output already means "output file" in your tool, use --format and keep it consistent everywhere.
  • Keep --json as an alias if you already shipped it. Removing a flag breaks scripts; map --json to -o json and deprecate it gently, as in versioning and deprecating CLI flags.
  • Say what the default is. The help text should state the TTY rule. A user who sees JSON Lines in | less will check --help first.
  • Errors stay on stderr in every format. An error in JSON mode can itself be JSON, but it still goes to stderr with a non-zero exit code; see reporting machine-readable errors in JSON mode.
  • Empty results are still valid output. [] for JSON, a header row for CSV, nothing for JSON Lines, and a one-line "no servers found" on stderr for the table.
Printing nothing correctly What each output format should print when a command finds no records. Printing nothing correctly Format stdout stderr table nothing no servers found json [] nothing jsonl nothing (zero lines) nothing csv the header row only nothing An empty result is still valid output, and the exit code is still 0.

Adding a format later

The payoff of the registry design shows the first time someone asks for a new format. Adding YAML is one function and one enum member:

class Format(str, Enum):
    ...
    yaml = "yaml"


def _yaml(rows, columns):
    import yaml                                  # optional dependency, imported lazily
    return yaml.safe_dump([{c: r.get(c) for c in columns} for r in rows], sort_keys=False)


RENDERERS[Format.yaml] = _yaml

Every listing command gains -o yaml without being touched, --help lists it automatically, and the existing tests keep passing. If the dependency is optional, catch the ImportError inside the renderer and tell the user which extra to install, as described in optional dependencies and extras for CLIs.

Testing the behaviour

CliRunner captures stdout into a buffer, which is not a TTY, so the default format under test is jsonl. That is convenient — assert the default explicitly, then test each format by name:

# tests/test_output_flag.py
import csv
import io
import json

from typer.testing import CliRunner

from fleet.cli import app

runner = CliRunner()


def test_default_in_a_pipe_is_jsonl(monkeypatch):
    monkeypatch.delenv("FLEET_OUTPUT", raising=False)
    result = runner.invoke(app, ["list"])
    assert result.exit_code == 0
    first = json.loads(result.stdout.splitlines()[0])
    assert first["name"] == "web-1"


def test_json_is_one_array():
    data = json.loads(runner.invoke(app, ["list", "-o", "json"]).stdout)
    assert [s["name"] for s in data] == ["web-1", "web-2", "db-1"]


def test_csv_has_header_and_rows():
    text = runner.invoke(app, ["list", "-o", "csv"]).stdout
    rows = list(csv.DictReader(io.StringIO(text)))
    assert rows[1]["status"] == "hot"


def test_env_var_sets_default(monkeypatch):
    monkeypatch.setenv("FLEET_OUTPUT", "csv")
    assert runner.invoke(app, ["regions"]).stdout.startswith("region,servers\n")


def test_unknown_format_is_a_usage_error():
    result = runner.invoke(app, ["list", "-o", "xml"])
    assert result.exit_code == 2

Parse the output in tests instead of comparing strings, except for one snapshot per format at the command level — that is where you want a reviewer to see a changed key. Testing Click commands with CliRunner covers the runner's quirks, including separating stdout from stderr.

Conclusion

A format flag is a small feature with a large effect on how scriptable a tool feels. Get the boundary right — commands return records, renderers print them — and the flag itself is a few lines: an Enum, a dictionary of functions and a shared option. From there, CSV details, field selection and file export all plug into the same records.

Frequently asked questions

Why default to JSON Lines rather than JSON in a pipe?

JSON Lines streams: head, grep and jq -c can work on the first record before the last one exists, and a consumer never has to hold the whole array in memory. A single JSON array is better when the consumer wants the complete result, so offer both and pick JSON Lines for the implicit pipe default.

Should the format option be global or per command?

Per command, usually, because only listing commands support every format. A global --output on the root group forces commands that print a single confirmation to handle csv. If most of your commands list things, a global option stored on the context works too; see global options vs per-command options.

How do I print dates and decimals in JSON?

Convert them before rendering: datetime.isoformat() for timestamps and either float or a string for Decimal, depending on whether precision matters. Alternatively pass default=str to json.dumps, which handles both — but be explicit for the fields scripts care about.

Can I let users pick a table style?

You can, but keep it inside the table renderer — for example -o table plus a --no-headers flag, or a wide format that shows extra columns as kubectl does. Do not let table styling options leak into the machine formats.