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.
argparseneedsRawDescriptionHelpFormatter, 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.
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
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.
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
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.