Help output is the documentation that ships with your tool. It is read far more often than anything on a website, it cannot go stale relative to the code it describes, and it is where a user decides whether your tool is worth learning. It also tends to be the least deliberately written text in the whole project.
TL;DR
- One line per option: what it does, what it accepts, what the default is.
- Keep the root help screen a map, not a catalogue — group commands by subject area.
- Put two to four real examples in an epilog; people copy rather than read.
- Generate reference docs and man pages from the command tree so they cannot drift.
- Renaming a flag or changing a default is a breaking change: alias, warn, then remove.
What a help screen is made of
Three of the four parts are generated for you. The usage line comes from your parameter declarations, the option list from their help strings, and the summary from the first line of the docstring. What you control is the words inside them — and the epilog, which is the only part you write from scratch.
The usage line follows conventions that predate every framework in this handbook, and following them means users already know how to read it:
Where authors go wrong is the summary. It appears twice — on the command's own help screen and, more importantly, next to the command name on its parent's screen — so it is the single most-read sentence in your tool:
@app.command()
def sync(source: Path) -> None:
"""Upload changed files from SOURCE to the configured bucket."""
That first line is the summary. Everything after a blank line becomes the longer description, which means a docstring written for developers ("Implements the sync algorithm described in ADR-14") becomes user-facing text nobody can act on.
Option lines that carry information
A user scanning twelve options is reading twelve lines of about eight words each. The ones that say nothing cost them attention they needed for the ones that do:
# says nothing
retries: Annotated[int, typer.Option(help="Sets the retries.")] = 3
# says something
retries: Annotated[int, typer.Option(help="Attempts per file before giving up.")] = 3
Add show_default=True (Click) or let Typer print it automatically, so the reader does not have to
guess what happens when they omit the flag. For values with units, put the unit in the help text
and, where it helps, in the metavar:
timeout: Annotated[float, typer.Option(help="Seconds to wait per attempt.", metavar="SECONDS")] = 30.0
Three more habits worth adopting. Start with a verb — "Attempts per file", "Skip verification", "Write output to PATH". Mention the partner flag when two interact ("Ignored unless --watch is set"). And keep each line to one screen width, because the framework will wrap it and a wrapped option line is much harder to scan.
The root screen is a map
A root --help that lists thirty verbs is a catalogue. A root that lists five subject areas is a
map, and the difference decides whether a new user finds anything.
Usage: mytool [OPTIONS] COMMAND [ARGS]...
Manage deployments and the data behind them.
Commands:
sync Upload changed files to the configured bucket.
db Database maintenance: migrate, seed, dump.
remote Manage remote endpoints.
doctor Print environment and configuration diagnostics.
Four entries, each with a summary that says what lives underneath. When the list stops fitting on a screen, that is the signal to add a level rather than another top-level verb — the same rule as in structuring multi-command CLIs.
Examples belong in the tool
The question a help screen most often fails to answer is "what do I actually type". An epilog with two to four real invocations answers it:
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"
),
)
For argparse, the same thing needs formatter_class=argparse.RawDescriptionHelpFormatter, or the
newlines are collapsed and the examples become a paragraph.
Choose examples that teach a habit, not just a syntax: the piped form shows that stdout is data,
and the --yes form shows the automation path exists. And make sure they work — an example that
errors is worse than none, which is a good argument for running them in a test.
Documentation generated from the tree
Reference documentation retyped into a README drifts within one release. Generating it from the command definitions makes drift impossible:
typer mytool.cli utils docs --output docs/reference.md # Typer's own generator
click-man and sphinx-click do the equivalent for Click, producing man pages and Sphinx pages
respectively. Whichever you use, run it in CI and fail the build when the generated output differs
from what is committed — that turns "the docs are out of date" into a failing check rather than a
discovery.
What generation cannot produce is the why: when to use a command, what the trade-offs are, how it fits a workflow. Keep those hand-written and keep the reference generated, and neither has to pretend to be the other.
Changing the interface later
Flags are a public API. The changes people underestimate are the ones that leave the name intact — changing a default, or changing what a flag means — because nothing in a user's script looks different and the behaviour has moved underneath them.
The mechanics are cheap: accept the old name as a hidden alias, warn on stderr naming the replacement and the removal version, and remove it at a major release. The deprecation guide covers the implementation and the changelog entries that go with it.
Width, colour and the other terminal
Help output is read in three places — an interactive terminal, a pipe into less or grep, and a
CI log — and it should look sensible in all three.
Both frameworks size output to the terminal and fall back to a fixed width when there is none. The one thing worth overriding is the upper bound: a help screen stretched across a 200-column window is much harder to read than one clamped to around 100.
app = typer.Typer(context_settings={"max_content_width": 100})
CONTEXT_SETTINGS = {"max_content_width": 100, "help_option_names": ["-h", "--help"]}
@click.group(context_settings=CONTEXT_SETTINGS)
def cli() -> None:
...
help_option_names is worth setting while you are there. -h is what most people type first, and
neither framework enables it by default.
Colour follows the same rule as the rest of your output: on for a terminal, off for anything else,
and off when NO_COLOR is set. Both frameworks handle the detection; what they cannot know is
whether your epilog's ANSI art is essential, which is an argument for not having any.
Errors are help output too
The text a user sees when they get an invocation wrong belongs in the same category, and it is read more carefully than the help screen — because at that moment they are stuck.
Both frameworks produce a decent default:
Usage: mytool sync [OPTIONS] SOURCE
Try 'mytool sync --help' for help.
Error: Invalid value for '--retries': 'x' is not a valid integer.
Three things make that work: the usage line reminds them of the shape, the hint names the command to get more, and the error names the offending option and value. When you write your own errors, keep the same three ingredients:
raise typer.BadParameter(
"expected a duration like 30s, 5m or 2h",
param_hint="--timeout",
)
param_hint is what puts the flag name in the message. Without it the user gets a sentence with
no indication of which of their twelve arguments was wrong.
For failures that are not usage errors — a missing file, an unreachable service — the equivalent courtesy is naming the input and suggesting the next step, which is covered in friendly error messages.
Documenting the parts that are not flags
A complete CLI has three surfaces beyond its options, and all three are routinely undocumented.
Environment variables. If your tool reads MYTOOL_TOKEN or honours NO_COLOR, say so. The
natural place is the root epilog and the man page's ENVIRONMENT section:
Environment:
MYTOOL_TOKEN API credential; required for remote commands.
MYTOOL_CONFIG Path to a config file, equivalent to --config.
NO_COLOR Disable colour output entirely.
Configuration files. Users need to know which paths are searched and in what order, because
"the tool is ignoring my setting" is almost always a question about search order. Documenting the
precedence chain — and offering a config show --origin command — removes most of those questions.
Exit codes. Anyone scripting your tool needs the list, and a tool that documents its codes is one people are willing to branch on:
Exit codes:
0 success
1 the operation failed
2 the command line was invalid
65 input data was malformed
78 configuration was invalid
130 cancelled by the user
All three are cheap to write once and expensive to leave out, because each one generates a recurring class of question that nothing in the option list answers.
Keeping help honest in CI
Two small checks stop help output regressing, and both are worth having before the tool has many users.
def test_root_help_lists_every_command(cli):
result = cli.invoke(app, ["--help"])
assert result.exit_code == 0
for name in ("sync", "db", "remote", "doctor"):
assert name in result.stdout
def test_every_option_has_help_text():
for command in app.registered_commands:
for param in command.params:
assert param.help, f"{command.name}: --{param.name} has no help text"
The second one is the useful one. It is three lines, it catches the option someone added in a hurry, and it is the difference between a help screen that stays complete and one that quietly develops gaps. Pair it with a snapshot of the root help screen — see snapshot testing CLI output — and any change to what users read shows up as a reviewable diff.
A worked rewrite
The fastest way to see the difference is to take a real help screen and fix it. Here is one that a tool might ship without thinking about:
Usage: mytool sync [OPTIONS] SRC
Sync command.
Options:
--retries INTEGER
--timeout FLOAT
--mode TEXT
--force
--help Show this message and exit.
Six problems, all of them common. The summary says nothing. No option documents its default. SRC
is an abbreviation the user has to guess at. --mode accepts "TEXT", which could mean anything.
--force gives no hint of what it overrides. And there are no examples.
The declarations that fix it:
@app.command()
def sync(
source: Annotated[Path, typer.Argument(
exists=True, file_okay=False, metavar="SOURCE",
help="Directory to upload.",
)],
retries: Annotated[int, typer.Option(
min=1, max=10, help="Attempts per file before giving up.",
)] = 3,
timeout: Annotated[float, typer.Option(
metavar="SECONDS", help="Seconds to wait for each upload.",
)] = 30.0,
mode: Annotated[SyncMode, typer.Option(
case_sensitive=False, help="What to do about files missing locally.",
)] = SyncMode.keep,
force: Annotated[bool, typer.Option(
help="Upload even when the remote copy looks newer.",
)] = False,
) -> None:
"""Upload changed files from SOURCE to the configured bucket."""
Which renders as:
Usage: mytool sync [OPTIONS] SOURCE
Upload changed files from SOURCE to the configured bucket.
Arguments:
SOURCE Directory to upload. [required]
Options:
--retries INTEGER RANGE Attempts per file before giving up. [1<=x<=10; default: 3]
--timeout SECONDS Seconds to wait for each upload. [default: 30.0]
--mode [keep|delete] What to do about files missing locally. [default: keep]
--force / --no-force Upload even when the remote copy looks newer. [default: no-force]
--help Show this message and exit.
Nothing was added to the implementation — the extra information came from the enum, the range, the metavar and four sentences. That is the general shape of improving help output: most of it is already available to the framework, and the rest is one line of English per option.
The remaining gap is examples, which the epilog covers. Two lines under this command turn "what
does --mode delete do to my files" into something a cautious user can test:
Examples:
mytool sync ./data --dry-run # show what would change
mytool sync ./data --mode delete # mirror: remove remote files gone locally
Where to go next
- Writing help text users actually read — summaries, option lines, metavars and the wording that survives scanning.
- Adding examples and epilogs to help output — where examples go in each framework, and how to keep them correct.
- Generating man pages and docs from a CLI — man pages, markdown reference and the CI check that stops drift.
- Versioning and deprecating CLI flags — aliases, warnings and removals that do not break scripts.
Key takeaways
- The command summary is the most-read sentence in your tool; write it for users, not developers.
- Every option line should say what it does, what it takes and what the default is.
- The root help screen should route, not enumerate.
- Examples in an epilog answer the question the option list cannot.
- Generate reference documentation; hand-write the reasoning; check both in CI.
Frequently asked questions
How long should help output be?
The root screen should fit on one terminal screen. A command's own screen can be longer, but if it runs past two screens the command probably has too many options — or some of them belong in a config file, where they can be set once instead of typed.
Should help text be localised?
Rarely worth it for developer tooling, where English is the working language and the flag names are English anyway. If your audience genuinely needs it, both Click and Typer accept translated strings, and the practical difficulty is not translation but keeping examples and error messages consistent with it.
Is Rich-formatted help a good idea?
Typer's default Rich help is pleasant for humans and occasionally awkward in logs and narrow
terminals. It degrades reasonably, and rich_markup_mode=None turns it off if you need plain
output. What matters more than the styling is the wording underneath it.
Where should the exit codes be documented?
In the help epilog for the root command, and in the man page's EXIT STATUS section. Users of scripts look for them there before they look on a website, and a tool that documents its codes is one people are willing to branch on.
Do I need a man page?
If your tool is installed by a system package manager, yes — users expect man mytool to work.
For a tool installed with pipx, it is a nice extra rather than an expectation, and generating one
from the command tree costs a build step.
How do I document a command that only makes sense with another?
Say so in the summary, in both directions: "Requires a prior mytool init" on one and "Prepares a
directory for mytool sync" on the other. Cross-references in help text are cheap and they are the
only navigation a reader has while inside the terminal.