Input & UX

Adding Examples and Epilogs to Help Output

Put runnable examples in CLI help - epilogs in Typer, Click and argparse, choosing which invocations to show, and testing that they still work.

Updated

An option list tells a reader what exists. It rarely tells them what to type. Two or three real invocations in an epilog close that gap, and they are the part of a help screen people actually copy.

TL;DR

  • Put examples in the epilog — the block after the option list.
  • Show the common case, a piped invocation, and the non-interactive form.
  • argparse needs RawDescriptionHelpFormatter, or the newlines collapse.
  • Keep the example strings in one module so the epilog, the docs and the tests share them.
  • Run them in a test — an example that no longer works is worse than none.

Prerequisites

A CLI with at least one command worth demonstrating, and pytest if you want the examples verified.

Where the epilog goes

import typer

app = typer.Typer(
    help="Manage deployments and the data behind them.",
    epilog=(
        "Examples:\n"
        "  mytool sync ./data --retries 5\n"
        "  mytool sync ./data --dry-run | tee plan.txt\n"
        "  mytool db migrate --yes            # non-interactive, for CI\n"
    ),
)
@click.command(epilog="Examples:\n\n  mytool sync ./data --retries 5\n")
def sync() -> None:
    ...
parser = argparse.ArgumentParser(
    prog="mytool",
    description="Manage deployments and the data behind them.",
    epilog=(
        "examples:\n"
        "  mytool sync ./data --retries 5\n"
        "  mytool sync ./data --dry-run | tee plan.txt\n"
    ),
    formatter_class=argparse.RawDescriptionHelpFormatter,
)

The argparse formatter is the detail that trips people up: without it the epilog is re-wrapped as a paragraph and your carefully aligned commands become one run-on line. Typer preserves newlines by default when Rich formatting is on; with rich_markup_mode=None it behaves like plain Click.

Which epilog a reader meets Epilogs exist at the root and on individual commands, so examples appear next to whichever help screen the reader is on. Which epilog a reader meets mytool --help root epilog: two cross-cutting examples mytool sync --help this command's real invocations mytool db --help group-level examples mytool doctor --help often needs none at all put examples where the reader will be when the question arises A root epilog listing every command's examples is documentation nobody scrolls.

Epilogs work at every level. The root epilog should show a couple of representative invocations across the tool; a command's own epilog shows that command's real uses. Put them where the user will be when the question arises.

Choosing which examples to show

What makes an example useful Qualities of a good command line example in help output, and the kinds of example that waste the reader's attention. What makes an example useful Worth including Realistic values, not FOO and BAR The safe form of a destructive command A piped invocation, showing stdout is data The non-interactive form for scripts Wasted lines Repeating the usage line with placeholders Every flag combination, exhaustively Examples that no longer run Output pasted in full, filling the screen Three good examples beat a dozen; the reader is looking for the one closest to their task.

Three or four examples is the useful range. Past that the epilog becomes documentation people scroll past, and the marginal example is answering a question nobody asked.

Which examples earn their place A table of example types worth including in help output: the common case, a piped invocation, a destructive command with its guard, and a scripted form. Which examples earn their place Example Shows Why it helps The common case the shortest useful invocation answers "what do I type" Piped stdout is data proves the tool composes Destructive + guard --yes and --dry-run teaches the safe habit Scripted flags instead of prompts shows the automation path Four examples cover most questions; a dozen turns the help screen into documentation nobody scrolls.

The set that covers most needs:

Examples:
  mytool sync ./data                     # the common case
  mytool sync ./data --dry-run           # see what would change first
  mytool status --json | jq '.[] | select(.healthy == false)'
  mytool db migrate --yes                # non-interactive, for CI

Each is doing a different job. The first answers "what do I type". The second teaches the safe habit before a destructive command. The third demonstrates that stdout is data — which is how people discover your tool composes. The fourth shows the automation path exists, which is the question every CI author has.

Use realistic values. mytool sync ./data reads as a real command; mytool sync <SOURCE> is the usage line again, and mytool sync FOO teaches nothing.

Keeping examples in one place

Where an example should live Examples appear in the command epilog, in the generated documentation and in the README, all rendered from one tested source. Where an example should live One examples module strings plus expected output Epilog rendered into --help Docs & README same strings, one source renders into and into Keeping examples in one module is what lets a test run them and prove they still work.

The same examples belong in the epilog, in the generated documentation and often in the README. Copying them into three files guarantees they diverge, so keep the strings in one module:

# src/mytool/examples.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Example:
    command: str
    comment: str = ""

ROOT_EXAMPLES = (
    Example("mytool sync ./data", "upload changed files"),
    Example("mytool sync ./data --dry-run", "show what would change"),
    Example("mytool status --json", "machine-readable status"),
)

def render_epilog(examples: tuple[Example, ...]) -> str:
    width = max(len(e.command) for e in examples)
    lines = "\n".join(
        f"  {e.command.ljust(width)}   # {e.comment}" if e.comment else f"  {e.command}"
        for e in examples
    )
    return f"Examples:\n{lines}\n"
app = typer.Typer(help="…", epilog=render_epilog(ROOT_EXAMPLES))

Now the alignment is computed rather than maintained by hand, the documentation generator can import the same tuple, and — most importantly — a test can run them.

UX considerations

Do not paste output. An example is a command, not a transcript. Including twenty lines of output for each one fills the screen and dates quickly; if the output is the point, that belongs in documentation with room for it.

Comment sparingly and in the same column. One short comment per line, aligned, reads as a table. Comments of varying length at varying positions read as noise.

Show flags that make the example safe. --dry-run in an example next to a destructive command teaches a habit at the exact moment it matters. This is the highest-value line in most epilogs.

Mind the width. An example longer than about 76 characters wraps in a narrow terminal and loses its shape. If a realistic invocation is genuinely that long, that is worth noticing — it may mean the command needs better defaults or a config file.

Testing the behaviour

Examples decay silently: a flag is renamed, an argument becomes required, and the epilog still shows the old form. Running them in a test removes the possibility.

import shlex
import pytest
from typer.testing import CliRunner

from mytool.cli import app
from mytool.examples import ROOT_EXAMPLES

runner = CliRunner()

@pytest.mark.parametrize("example", ROOT_EXAMPLES, ids=lambda e: e.command)
def test_documented_examples_still_parse(example, tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    (tmp_path / "data").mkdir()

    argv = shlex.split(example.command)[1:]          # drop the program name
    result = runner.invoke(app, [*argv, "--dry-run"] if "--dry-run" not in argv else argv)

    assert result.exit_code == 0, result.output

Two notes on that test. It forces --dry-run where the example does not already have it, so running the suite cannot upload anything. And it uses shlex.split, which handles the quoting in an example the way a shell would — a small detail that stops the test disagreeing with reality.

For examples that genuinely cannot run in a test (they need a network, or a real bucket), assert at least that they parse: invoke with a mocked core and check for exit code 0 rather than 2.

Conclusion

An epilog is three or four lines of plain text that answer the question an option list cannot. Keep the examples realistic, keep them in one module, render the epilog from that module, and let a test run them. The cost is an afternoon once; the benefit is that the first thing a new user copies actually works.

Frequently asked questions

Should every command have an epilog?

No — only the ones where the invocation is not obvious from the option list. A mytool doctor with no arguments needs none. A command with three interacting flags, or one whose output is meant to be piped, benefits enormously.

Where should examples live if my help is Rich-formatted?

The same place. Typer renders the epilog inside its help panel and preserves the line breaks. If you want emphasis, Rich markup works there too — but plain text is more portable, since the same string ends up in generated documentation and man pages.

How do I show an example that needs a config file?

Show the command and mention the prerequisite in a comment: mytool deploy --env prod # needs a configured remote. What to avoid is an example that silently assumes state, because the reader tries it, gets an error, and concludes the documentation is wrong.

Can I generate examples from tests instead?

That inversion works nicely: mark certain tests as documentation examples and render the epilog from their invocations. It guarantees the examples run, at the cost of tests that are now shaped by documentation needs. Keeping a shared module and testing it is the simpler arrangement.

Do examples belong in the man page too?

Yes — EXAMPLES is a conventional man page section, and readers look for it. If you generate the man page from your command tree, the epilog usually becomes that section automatically, which is one more reason to keep the strings in a module both can import.

How many examples is too many?

Past four, most readers stop scanning — and the fifth example is usually a variation rather than a new idea. If you genuinely have more worth showing, that is documentation: link to a page from the epilog rather than growing it.

Should examples use long or short flags?

Long, in documentation and examples. --dry-run is self-explanatory to someone reading a script six months later, where -n is a lookup. Short flags are for typing, long flags are for reading, and an example is something people read before they type it.

Can the epilog show a shell function or alias?

It can, and it is often the most useful line for a tool people run constantly — a two-line function wrapping a common invocation saves more typing than any flag. Keep it to one short definition, and make sure it is valid in both bash and zsh, since a snippet that only works in one is a support question rather than a shortcut.

What if my examples need a fixture directory?

Refer to something the reader plausibly has (./data, ./config.toml) rather than to a fixture that only exists in your repository. In the test, create it in tmp_path first — the example stays realistic for readers and runnable for CI, which is the whole reason to keep the strings in one module.