Nobody reads a help screen. They scan it, looking for the one line that answers their question, and they give up quickly. Writing help text well is mostly about respecting that: put the answer where the eye lands, and cut everything that does not carry information.
TL;DR
- The first line of the docstring is the summary shown next to the command name — write it for users.
- One line per option, starting with a verb, naming what it accepts and what the default is.
- Use a metavar to say what a value is:
SECONDS,PATH,NAME. - Enable
-has well as--help; it is what people type first. - Clamp the width to around 100 columns so long lines stay readable.
Prerequisites
A Click or Typer application with a few commands. Everything here applies to argparse too, with
help= and metavar= in place of the decorators.
The summary is the most-read sentence
@app.command()
def sync(source: Path) -> None:
"""Upload changed files from SOURCE to the configured bucket.
Files already present with the same checksum are skipped. Use --dry-run to
see what would happen without uploading anything.
"""
The first line appears twice: on this command's help screen and, crucially, beside the command name
on the parent screen. That second appearance is how someone scanning mytool --help decides which
command they want, which makes it worth more attention than the rest of the docstring combined.
Three rules make summaries useful:
Start with a verb, in the imperative. "Upload changed files…" not "This command uploads…" — shorter, and consistent with every other tool the reader uses.
Name the domain object. "Upload changed files to the bucket" beats "Perform a synchronisation operation", which says nothing a reader did not already infer from the command name.
Fit on one line. Around 60 characters is what fits beside a command name in most layouts; longer and it wraps or gets truncated, which looks careless.
Option lines that say something
An option's help text has one job: let the reader decide whether they need it. That means saying what it does, not what it is called.
# restates the flag name
force: Annotated[bool, typer.Option(help="Force mode.")] = False
# tells the reader when to reach for it
force: Annotated[bool, typer.Option(help="Upload even when the remote copy looks newer.")] = False
Defaults belong in the option line rather than in prose. Typer prints them automatically; Click
needs show_default=True, which is worth setting globally:
CONTEXT_SETTINGS = {"show_default": True, "help_option_names": ["-h", "--help"], "max_content_width": 100}
@click.group(context_settings=CONTEXT_SETTINGS)
def cli() -> None:
...
Those three settings together fix most of what is wrong with a default help screen: defaults become
visible, -h works, and lines stop stretching across a wide terminal.
Metavars: naming what a value is
By default the placeholder is derived from the type, which gives you INTEGER, TEXT and FLOAT
— accurate and unhelpful. A metavar says what the value means:
timeout: Annotated[float, typer.Option(metavar="SECONDS", help="Time to wait per attempt.")] = 30.0
output: Annotated[Path, typer.Option("--output", "-o", metavar="PATH", help="Write results here.")] = Path("-")
region: Annotated[str, typer.Option(metavar="REGION", help="Deployment region, e.g. eu-west-1.")] = "eu-west-1"
For a value from a fixed set, an Enum does better than any metavar — it produces
[keep|delete] in the help, restricts the input, and feeds shell completion:
class SyncMode(str, Enum):
keep = "keep"
delete = "delete"
mode: Annotated[SyncMode, typer.Option(case_sensitive=False, help="What to do about files missing locally.")] = SyncMode.keep
Ordering and grouping
Readers scan top to bottom, so order matters more than it looks. Put the options someone is most likely to need first, and keep the rarely used ones together at the bottom.
Click supports explicit option groups through third-party extensions, and Typer offers
rich_help_panel, which is the simplest way to break a long list into sections:
@app.command()
def sync(
source: Annotated[Path, typer.Argument(help="Directory to upload.")],
dry_run: Annotated[bool, typer.Option(help="Report without uploading.")] = False,
retries: Annotated[int, typer.Option(rich_help_panel="Tuning", help="Attempts per file.")] = 3,
timeout: Annotated[float, typer.Option(rich_help_panel="Tuning", metavar="SECONDS")] = 30.0,
) -> None:
...
Two panels — the common options and "Tuning" — turn a wall of twelve flags into something a reader can triage. If you find yourself wanting four panels, the command may be doing too much.
UX considerations
Width. Clamp to about 100 columns. A help screen stretched across a 200-column window forces the reader's eye to travel much further than it needs to, and the effect is that people stop reading half way along each line.
Colour. Typer's Rich-formatted help is pleasant interactively and should disappear when output
is redirected — which it does automatically, along with honouring NO_COLOR. What is worth checking
is that your own epilog does not contain hand-written escape codes that ignore those rules.
Terminology. Use the words your users use, not your internals'. If your code calls it a "reconciliation pass" and users call it a sync, the help text says sync. The internal vocabulary belongs in the code, where it helps, rather than in the interface, where it is a translation task imposed on the reader.
Length discipline. A good test: read the option list aloud. Anything that takes more than a breath is too long, and anything you find yourself skipping is not carrying information.
Testing the behaviour
Two checks keep help output from developing gaps, and both are short:
def test_every_parameter_has_help_text():
for command in app.registered_commands:
for param in command.params:
assert param.help, f"{command.name}: --{param.name} is undocumented"
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
The first is the one that pays off. It catches the option added in a hurry, it runs in milliseconds, and it makes "document your flag" a build requirement rather than a review comment.
For the wording itself, a snapshot test over the root help screen turns any change into a reviewable diff — see snapshot testing CLI output.
Conclusion
Help text is written once and read thousands of times. A verb-first summary, one informative line per option, a metavar that names the value, visible defaults and a sensible width cost perhaps twenty minutes across a whole tool — and they are the difference between a CLI that feels considered and one that feels like a wrapper somebody left running.
Frequently asked questions
How much detail belongs in help versus documentation?
Help answers "what can I type here"; documentation answers "why would I". Keep each option to one line and put conceptual material — how the sync algorithm decides what changed, how configuration layers — on a page you link to from the epilog.
Should the docstring be written for users or developers?
Users, because Click and Typer publish it. If a command genuinely needs implementation notes, put them in a comment rather than a docstring; the comment is for the next maintainer, and the docstring is part of the interface.
Does -h conflict with anything?
Only if you have already used -h for something else, which is worth avoiding — it is one of the
few short flags people expect to mean a specific thing. Add it with help_option_names and leave
it alone.
How do I document an option that only applies to some subcommands?
Declare it on those subcommands rather than globally. An option on the root callback appears on every help screen beneath it, which is misleading if only one command honours it — and confusing behaviour when a user passes it to a command that ignores it.
Should required options be marked in help?
Both frameworks mark required arguments automatically. For a required option, consider whether it should be an argument instead: a value the command cannot run without is usually positional. If it must be an option, say so in the help text as well, since scanners miss the marker.
How do I handle help text for an option with a computed default?
Say what the rule is rather than showing a value that will be wrong on someone else's machine:
"Defaults to the number of CPUs" or "Defaults to $XDG_CONFIG_HOME/mytool/config.toml". Both
frameworks accept a show_default string for exactly this, so the help line reads
[default: (number of CPUs)] instead of a number that happens to be true on the machine that
built the docs.
Should the help text mention the environment variable equivalent?
Yes, when one exists. A line ending "(env: MYTOOL_RETRIES)" is short and saves a search through
documentation. Click adds it automatically when you use envvar= with show_envvar=True, which
is the least effort for the most benefit.