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.
# 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:
UX considerations
- Name it
--output, short-o. That matches kubectl, the AWS and Azure CLIs and many more, so users guess it. If--outputalready means "output file" in your tool, use--formatand keep it consistent everywhere. - Keep
--jsonas an alias if you already shipped it. Removing a flag breaks scripts; map--jsonto-o jsonand 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
| lesswill check--helpfirst. - 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.
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.