Input & UX

Working with stdin, stdout and Pipes

Make a Python CLI compose - read piped input, keep stdout clean, emit stable JSON, survive broken pipes, and adapt when no terminal is attached.

Updated

The difference between a script and a tool people build on is usually composition: whether your command can sit in the middle of a pipeline without corrupting it, hanging, or crashing when the downstream program exits early. All of that comes down to how you treat three streams.

TL;DR

  • stdout is the result. Anything a pipe should carry goes there and nothing else does.
  • stderr is narration. Progress, warnings, errors and prompts — nothing should parse it.
  • Support - for reading stdin and writing stdout; it costs a few lines.
  • Catch BrokenPipeError at the boundary and exit quietly — | head is not a crash.
  • Check isatty before colour, progress bars and prompts.
What this section covers The streams section covers reading piped input, emitting machine-readable output, handling broken pipes, and adapting behaviour to whether a terminal is attached. What this section covers stdin, stdout & pipes how your tool joins a pipeline Reading input files, stdin and the "-" convention Emitting JSON output other programs can parse Broken pipes what happens under head Detecting a TTY colour, progress, prompts a tool that composes is a tool people build on These four behaviours are what separate a script from something you can put in a pipeline.

Three streams, three jobs

Three streams, three jobs The roles of standard input, standard output and standard error in a command line tool, and what belongs on each. Three streams, three jobs stdin input Data piped in, or a document the user redirected. Never prompt from it when it holds data. stdout the result Only what a downstream program should receive. Format changes here are breaking changes. stderr narration Progress, warnings, errors, prompts. Free to reword; nothing should parse it. if piping your command into another program should carry it, it belongs on stdout Almost every pipeline bug in a CLI is something written to the wrong one of these three.

Almost every pipeline bug in a CLI is something written to the wrong stream. The test is simple: if piping your command into another program should carry it, it belongs on stdout. Everything else — including the progress bar that makes the tool pleasant interactively — belongs on stderr, where it still reaches the user's terminal but never enters the pipeline.

Your tool in the middle of a pipeline A shell pipeline where the tool reads from standard input, writes results to standard output and sends progress to standard error, which reaches the terminal directly. Your tool in the middle of a pipeline Upstream cat, curl, another tool Your tool reads stdin, writes stdout Downstream jq, head, a file stdin stdout stderr bypasses the pipeline entirely, which is why narration belongs there and results do not.

In practice that means two output objects created once and imported everywhere:

# src/mytool/console.py
import sys
from rich.console import Console

out = Console(file=sys.stdout)        # results
err = Console(file=sys.stderr)        # progress, warnings, errors

Every print in the program then goes through one of them, and the question "which stream is this on" is answered at the call site rather than by grepping for stray print() calls later.

Reading input that may be piped

A tool that reads stdin unconditionally appears to hang when run with no arguments, because it is waiting for input a person at a prompt has no idea they are meant to type. Check first:

import sys
from pathlib import Path

def read_document(source: Path | None) -> str:
    """Read from SOURCE, or from stdin when SOURCE is '-' or omitted with data piped in."""
    if source is not None and str(source) != "-":
        return source.read_text(encoding="utf-8")

    if sys.stdin.isatty():
        raise typer.BadParameter("no input: pass a file, or pipe a document on stdin")

    return sys.stdin.read()

The - convention is worth honouring in both directions. mytool apply - reads the document from stdin; mytool export -o - writes results to stdout. Users of Unix tools expect it, and supporting it is what lets your command appear anywhere in a pipeline rather than only at the start.

One detail that bites across platforms: name the encoding. sys.stdin.read() uses the platform default, which differs between Linux, macOS and Windows and between locales. For text, reconfigure explicitly; for binary data, use the underlying buffer:

sys.stdin.reconfigure(encoding="utf-8", errors="strict")
raw_bytes = sys.stdin.buffer.read()          # when the input is not text at all

Output other programs can parse

The moment someone wants to script your tool, they will parse whatever you print. Giving them a real format instead means the human-readable output stops being an interface you have to keep stable.

@app.command()
def status(
    json_output: Annotated[bool, typer.Option("--json", help="Emit JSON on stdout.")] = False,
) -> None:
    rows = core.collect_status()
    if json_output:
        typer.echo(json.dumps({"version": 1, "data": [r.as_dict() for r in rows], "warnings": []}))
        return
    render_table(rows)

Use one envelope for every command. A tool where status --json returns a list and list --json returns an object forces consumers to write per-command parsers, which is exactly the friction the flag was meant to remove.

For long or streaming results, newline-delimited JSON is the better shape: one object per line, no enclosing array, so a consumer can start work before your command has finished — and your memory stays flat.

Surviving a closed pipe

mytool list | head -n 10 is an ordinary thing to type. When head has what it needs it exits and closes the pipe, and your next write raises BrokenPipeError. The default behaviour — a traceback, plus a second warning from the interpreter at shutdown — makes a completely normal shell idiom look like a crash.

def main() -> None:
    try:
        app()
    except BrokenPipeError:
        # Redirect stdout so the interpreter's own flush at shutdown cannot fail too.
        devnull = os.open(os.devnull, os.O_WRONLY)
        os.dup2(devnull, sys.stdout.fileno())
        sys.exit(0)

Exit 0: nothing went wrong. The downstream consumer decided it had enough, which is the pipeline working as designed. The broken pipe guide covers the platform differences and why the dup2 line is needed.

Behaving differently when nobody is watching

Five behaviours should change when output is not a terminal, and each is one isatty check. Rich and Click handle most of them automatically; what they cannot decide is whether a progress bar should become periodic plain lines or disappear entirely, and whether a prompt should fail or use a default.

Route the check through one helper so the answer is consistent and testable:

def interactive() -> bool:
    return sys.stdin.isatty() and sys.stderr.isatty() and not os.environ.get("CI")
Making a command safe to pipe The behaviours a command line tool needs in order to work correctly inside a shell pipeline, and the mistakes that break composition. Making a command safe to pipe Composes Results on stdout, everything else on stderr Exits quietly when the downstream closes early Accepts "-" for stdin and stdout Emits a stable format under --json Breaks the pipeline A progress bar written to stdout A traceback on BrokenPipeError Colour codes in redirected output A prompt that blocks with no terminal Each right-hand item is a bug report that arrives as "your tool broke my script".

Streaming rather than collecting

The shape of a command that produces many records decides how it behaves in a pipeline. Collecting everything and printing at the end is simpler to write and worse in three ways: memory grows with the input, the downstream program waits for the whole run, and a failure halfway through produces nothing at all.

# collects — memory grows, nothing appears until the end
def export(period: str) -> None:
    rows = list(core.collect(period))
    typer.echo(json.dumps([row.as_dict() for row in rows]))

# streams — flat memory, output starts immediately
def export(period: str) -> None:
    for row in core.collect(period):          # a generator
        typer.echo(json.dumps(row.as_dict()))

The streaming version needs core.collect to be a generator rather than a function returning a list, which is usually a small change and occasionally a design improvement in its own right — the logic layer stops deciding how much to hold in memory, and the caller decides instead.

Two details make streaming work properly in practice. Flush on a sensible boundary: Python buffers stdout when it is not a terminal, which is what you want for throughput, but it means a long-running command produces nothing for a while. If a downstream consumer needs records promptly, flush every few hundred lines rather than every line:

for index, row in enumerate(core.collect(period), start=1):
    typer.echo(json.dumps(row.as_dict()))
    if index % 500 == 0:
        sys.stdout.flush()

And report progress on stderr, where it does not enter the pipeline:

if index % 1000 == 0:
    err.print(f"[dim]{index} rows exported…[/]")

Composing with the shell, deliberately

A tool that reads stdin and writes stdout gains a whole vocabulary of uses you never wrote code for. It is worth designing for them explicitly, because a few small decisions decide whether these work:

mytool export --json | jq '.data[] | select(.size > 1e6)'      # filter
mytool export --json | mytool import -                          # round-trip
cat jobs.ndjson | mytool apply - --dry-run                      # bulk apply
mytool list --json | head -5                                    # sample
mytool export -o - | gzip > backup.json.gz                      # stream to storage
diff <(mytool export --json) <(ssh host mytool export --json)   # compare environments

Four properties make all six possible, and each has already appeared above: results on stdout, - supported in both directions, a stable JSON shape, and a clean exit when the downstream closes. The last example — process substitution comparing two environments — is the kind of use nobody plans for and everybody appreciates.

The round-trip case (export | import -) is worth testing explicitly, because it is both a genuinely useful operation and a strong check that your output format contains everything your input format needs.

Buffering, ordering and the surprises

Three behaviours around streams surprise people often enough to be worth stating.

stdout and stderr are buffered differently. stdout is line-buffered on a terminal and block- buffered when redirected; stderr is unbuffered or line-buffered. The visible consequence is that when both are redirected to the same file, the interleaving does not match the order you wrote them. If ordering matters — a log people read alongside results — send everything to one stream, or flush deliberately.

Redirection changes behaviour, not just destination. mytool status on a terminal and mytool status > out.txt produce different bytes if you are colouring output, sizing tables to the window, or drawing progress. That is correct and intended, but it means a bug report that says "the output is wrong in a file" is usually about detection rather than about formatting.

Exit code and output are separate channels. A command can print a perfectly good JSON document and still exit non-zero — a partial failure, say. Consumers should check the status before parsing, and your documentation should say what output accompanies each code. Putting a "status": "error" field in the JSON instead of using the exit code is a common mistake: shell scripts branch on the status, not on the payload.

Testing pipeline behaviour

All of this is testable in-process, which means it belongs in the ordinary suite rather than in a shell script somebody runs by hand.

def test_results_go_to_stdout_and_narration_to_stderr(cli):
    result = cli.invoke(app, ["export", "--json"])

    assert result.exit_code == 0
    json.loads(result.stdout)                      # stdout is parseable on its own
    assert "exported" in result.stderr             # progress went elsewhere

def test_reads_a_document_from_stdin(cli):
    result = cli.invoke(app, ["apply", "-"], input='{"replicas": 3}')

    assert result.exit_code == 0

def test_empty_stdin_is_a_clear_error(cli):
    result = cli.invoke(app, ["apply", "-"], input="")

    assert result.exit_code == 65
    assert "no input" in result.stderr

The first test is the important one and the one people leave out. json.loads(result.stdout) fails loudly the moment anything non-JSON is written to stdout — a stray print, a progress line, a warning that went to the wrong stream — which is exactly the regression that breaks every consumer at once.

For broken-pipe behaviour, an in-process test is awkward, so use a subprocess in one test:

def test_survives_a_closed_downstream():
    tool = subprocess.Popen(["mytool", "list"], stdout=subprocess.PIPE)
    head = subprocess.Popen(["head", "-n", "5"], stdin=tool.stdout, stdout=subprocess.DEVNULL)
    tool.stdout.close()
    head.wait()

    assert tool.wait() == 0        # not a crash, not a non-zero status

Designing a command that filters

The most composable shape a command can have is a filter: read records from stdin, transform them, write records to stdout. Tools built that way chain with each other and with everything else in the shell.

@app.command()
def filter_failed(
    source: Annotated[Path, typer.Argument(help="NDJSON input, or - for stdin.")] = Path("-"),
) -> None:
    """Read deployment records and emit only the failed ones."""
    for line in read_lines(source):
        record = json.loads(line)
        if not record.get("healthy", True):
            typer.echo(json.dumps(record))
def read_lines(source: Path) -> Iterator[str]:
    if str(source) == "-":
        if sys.stdin.isatty():
            raise typer.BadParameter("no input: pipe NDJSON on stdin or pass a file")
        yield from sys.stdin
    else:
        with source.open(encoding="utf-8") as handle:
            yield from handle

Four properties make it a good citizen. It defaults to stdin, so it works mid-pipeline with no arguments. It streams, so memory stays flat and output appears immediately. It emits the same shape it consumes, so it can be chained with itself. And it writes nothing but records to stdout, so the next command's parser never chokes.

The same shape covers a surprising range of commands: filtering, enriching, reformatting, validating. When a command does not fit it — because it needs the whole input to produce one answer, for instance — that is worth noticing rather than forcing, and the honest version reads everything and emits one document.

What to do when both a file and stdin are possible

Commands often accept a path and support stdin, which raises a small design question people answer inconsistently: what happens when neither is given?

Three defensible answers, and the one to avoid:

Read stdin when data is waiting, otherwise error. The most Unix-like: mytool apply with a pipe behaves like mytool apply -, and without one it fails immediately with a message naming the argument. This is the behaviour of most filters.

Require the argument, with - as the explicit stdin form. Slightly more verbose for users and much harder to get wrong, because there is no implicit mode. Good for destructive commands, where "it read something I did not mean to give it" is expensive.

Default to a conventional file (./mytool.toml, ./jobs.ndjson), documented in the help text. Convenient for a tool with an obvious project-local input.

What to avoid is blocking on stdin with no indication. A command that hangs at a prompt with no output is indistinguishable from a crash, and the user's next move is Ctrl-C — which, if you have not handled it cleanly, prints a traceback on top of the confusion.

Where to go next

Key takeaways

  • Results go to stdout; everything else goes to stderr, without exception.
  • Support - in both directions so the tool composes in either position.
  • One JSON envelope across every command beats a shape per command.
  • A closed downstream is a normal end, not an error — exit 0, quietly.
  • One interactive() helper, consulted everywhere, keeps TTY behaviour consistent.

Frequently asked questions

How do I know whether data is being piped in?

sys.stdin.isatty() returns False when stdin is a pipe or a redirected file, which is the practical signal that data is waiting. It cannot tell you whether that data is empty — an upstream command that produced nothing still gives you a non-terminal stdin — so handle the empty case with a clear message rather than a parser traceback.

Should errors ever go to stdout?

No. Even for a command whose whole purpose is producing errors — a linter, say — the machine-readable findings are the result and belong on stdout, while the summary and any diagnostics about the run itself belong on stderr. Mixing them means a consumer has to filter your prose out of their data.

What exit code should I use when the pipe closes?
  1. Nothing failed: the downstream program decided it had enough output. Returning non-zero makes mytool list | head look like a failure to every wrapper script that checks the status.
Is it worth supporting - if my tool takes a file argument?

Yes, and it is about five lines. Without it, users pipe through a temporary file or process substitution, both of which are more fragile than the convention every Unix tool already implements.

How large an input can I read from stdin?

Whatever you stream. sys.stdin.read() loads everything into memory; iterating line by line, or reading in chunks, keeps usage flat regardless of size. For anything that might be a gigabyte — a log, an export — stream it, and your tool works on inputs nobody anticipated.

Does any of this work the same way on Windows?

Mostly. isatty, stream redirection and the - convention behave as expected, and PowerShell pipelines carry text between programs much as a Unix shell does. The differences worth knowing are that there is no SIGPIPE — a closed downstream surfaces as an OSError rather than a signal — and that the console encoding has historically not been UTF-8, which is why naming the encoding explicitly matters more there than anywhere else.

Should a filter preserve unknown fields it does not understand?

Yes, wherever it can. A filter that drops fields it did not expect silently breaks every downstream consumer that needed them, and it makes chaining two versions of your own tool lossy. Read into a model that keeps extras, or operate on the parsed mapping directly and write it back out.