Input & UX

Writing Help Text Users Actually Read

Write CLI help text people can scan - summaries, one-line option help, metavars, defaults, and the wording that survives a hurried reader.

Updated

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 -h as 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

Writing an option line worth reading Guidance for writing help text for a command line option, and the habits that make help screens unhelpful. Writing an option line worth reading Do this Say what it does, in one line, starting with a verb State the default and the unit Name what it accepts: a path, a count, one of three words Mention the related flag when two interact Not this "Sets the retries option" — restating the flag name A paragraph that wraps to four lines Jargon from your internals rather than the user's world Leaving the default undocumented A reader is scanning; every line that says nothing costs them the one that does.

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

Reading a usage line The conventions in a generated usage line: square brackets for optional items, angle brackets for placeholders, ellipsis for repeatable arguments. Reading a usage line Usage: mytool sync [OPTIONS] SOURCE [DEST]... [OPTIONS] optional, any order SOURCE required positional [DEST]... optional and repeatable Uppercase means "you supply this" Square brackets mean optional An ellipsis means one or more may follow the conventions users expect — These conventions predate every framework here; following them means users already know how to read your usage line.

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

How wide should help output be? A decision diagram for help output width: wrap to the terminal when attached to one, and to a fixed width when redirected. How wide should help output be? Is stdout a terminal? Yes — a person is reading it now Fit the window clamped to about 100 columns No — piped, redirected, in CI Fixed 80 stable diffs and snapshots A fixed width when redirected is what makes help output snapshot-testable.

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.