argparse's default help output is functional and flat: one "options" section listing every flag in definition order, metavars derived from destination names (--max-size MAX_SIZE), no examples, and line breaks in your description collapsed into a paragraph. For a script with three flags that is fine. For a real CLI with fifteen options it is a wall that users scroll past. argparse has everything needed to do much better without a third-party framework — argument groups, explicit metavars, epilogs, formatter classes — but the pieces are scattered through the documentation and one of them needs a small subclass. This guide assembles them into a help screen people can scan, and adds a test so it stays that way. It belongs to the argparse topic.
Prerequisites
- Python 3.10+; everything here is standard library. The "did you mean" suggestion shown later needs Python 3.14.
- An argparse-based CLI, possibly with subcommands as in argparse subparsers for subcommands.
Anatomy of a help screen
A good help screen has five parts, in this order: a usage line that shows what is required; a one-sentence description; groups of related options, each with a heading and optionally a sentence of explanation; defaults where they inform; and examples at the end. argparse produces the usage line automatically from your arguments. The rest is configuration.
The recipe
# src/backup/cli.py
from __future__ import annotations
import argparse
import sys
import textwrap
class Formatter(argparse.RawDescriptionHelpFormatter):
"""Keep the epilog's line breaks; show defaults only when they say something."""
def _get_help_string(self, action: argparse.Action) -> str:
text = action.help or ""
uninformative = (None, False, [], 0, argparse.SUPPRESS)
if "%(default)" in text or not action.option_strings or action.default in uninformative:
return text
return f"{text} (default: %(default)s)"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="backup",
description="Back up directories to local disk or S3.",
epilog=textwrap.dedent("""\
examples:
backup ~/docs --to /mnt/backup
backup ~/docs --to s3://bucket/docs --storage-class GLACIER
"""),
formatter_class=Formatter,
)
if sys.version_info >= (3, 14):
parser.suggest_on_error = True # "did you mean" for typos (3.14+)
parser.add_argument("sources", nargs="+", metavar="SOURCE", help="directories to back up")
dest = parser.add_argument_group("destination")
dest.add_argument("--to", required=True, metavar="DEST", help="local path or s3:// URL")
dest.add_argument("--storage-class", choices=["STANDARD", "GLACIER"], default="STANDARD",
help="S3 storage class")
filt = parser.add_argument_group("filtering", "Choose which files are copied.")
filt.add_argument("--exclude", action="append", default=[], metavar="GLOB",
help="skip matching paths (repeatable)")
filt.add_argument("--max-size", type=int, metavar="MB", help="skip files larger than this")
out = parser.add_argument_group("output")
verbosity = out.add_mutually_exclusive_group()
verbosity.add_argument("-v", "--verbose", action="count", default=0,
help="more detail (repeatable)")
verbosity.add_argument("-q", "--quiet", action="store_true", help="only print errors")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
print(f"backing up {len(args.sources)} source(s) to {args.to}")
return 0
The result, at 90 columns:
usage: backup [-h] --to DEST [--storage-class {STANDARD,GLACIER}] [--exclude GLOB]
[--max-size MB] [-v | -q]
SOURCE [SOURCE ...]
Back up directories to local disk or S3.
positional arguments:
SOURCE directories to back up
options:
-h, --help show this help message and exit
destination:
--to DEST local path or s3:// URL
--storage-class {STANDARD,GLACIER}
S3 storage class (default: STANDARD)
filtering:
Choose which files are copied.
--exclude GLOB skip matching paths (repeatable)
--max-size MB skip files larger than this
output:
-v, --verbose more detail (repeatable)
-q, --quiet only print errors
examples:
backup ~/docs --to /mnt/backup
backup ~/docs --to s3://bucket/docs --storage-class GLACIER
What each piece does
Argument groups (add_argument_group) only affect help; parsing is unchanged. Each group gets a heading and an optional description line. Grouping by purpose — where the data goes, which files, how much output — lets users find the option they need without reading all of them. Mutually exclusive groups can live inside a regular group, which is how -v and -q end up under "output" while still being enforced as exclusive; mutually exclusive options in argparse covers that mechanism.
Metavars replace the auto-generated placeholder. --max-size MB says the unit; --max-size MAX_SIZE says nothing. --to DEST and --exclude GLOB tell the reader what kind of value is expected before they read the description.
The epilog is the natural home for examples, which users read first. Adding examples and epilogs to help output covers what makes a good example.
The formatter. RawDescriptionHelpFormatter keeps the line breaks in the description and epilog, which the default formatter would re-wrap into one paragraph. The standard ArgumentDefaultsHelpFormatter appends (default: …) to every option — including (default: None), (default: False) and (default: []), which are noise. The small subclass shows a default only when it is informative and the option does not already mention it. _get_help_string has a leading underscore, but overriding it is exactly how the standard library's own ArgumentDefaultsHelpFormatter works, and it has been stable for many releases.
Width and colour
argparse wraps help to the terminal width, read from the COLUMNS environment variable or the terminal itself, with a margin. Python 3.14 also colours help and error output on terminals that support it, honouring NO_COLOR and FORCE_COLOR — the same conventions described in respecting NO_COLOR and FORCE_COLOR. On 3.14, suggest_on_error adds "maybe you meant" hints to invalid choices:
backup: error: argument --storage-class: invalid choice: 'GLACER', maybe you meant 'GLACIER'? (choose from STANDARD, GLACIER)
Help for subcommands
Subcommands have two help strings, and they appear in different places. help= is the one-line summary shown in the parent's list of commands; description= is the paragraph at the top of the subcommand's own --help. Set both, and tidy the command list itself with title and metavar on add_subparsers:
sub = parser.add_subparsers(title="commands", metavar="COMMAND", required=True)
run = sub.add_parser("run", help="run a backup now",
description="Copy the sources to the destination immediately.",
formatter_class=Formatter)
Without metavar, argparse prints the raw set of choices ({run,list,restore}) in the usage line and as the section entry, which gets unreadable after three or four commands. With it, the usage shows COMMAND and the "commands" section lists each subcommand with its summary — the layout users know from git help. required=True makes a missing subcommand a usage error instead of a silent no-op.
UX considerations
- Order groups by importance. Required and most-used options first; rarely used tuning options last. Within a group, the same principle applies.
- Write help in lowercase fragments, matching argparse's own
show this help message and exit. Consistency matters more than which style you pick. - Say units and formats in the metavar (
MB,SECONDS,YYYY-MM-DD) so the description can focus on meaning. - Hide internal options with
help=argparse.SUPPRESSrather than deleting them, if they must stay for compatibility. - Keep examples copy-pasteable and correct; they are the part of help most likely to be run verbatim.
Testing the behaviour
Help text is user-facing output and deserves a snapshot, rendered at a fixed width so it does not depend on the terminal running the tests:
# tests/test_help.py
import pytest
from backup.cli import build_parser, main
@pytest.fixture
def help_text(monkeypatch) -> str:
monkeypatch.setenv("COLUMNS", "90")
monkeypatch.setenv("NO_COLOR", "1")
return build_parser().format_help()
def test_groups_appear_in_order(help_text):
headings = [line for line in help_text.splitlines() if line.endswith(":") and not line[0].isspace()]
assert headings == ["positional arguments:", "options:", "destination:", "filtering:",
"output:", "examples:"]
def test_only_informative_defaults_are_shown(help_text):
assert "(default: STANDARD)" in help_text
assert "(default: None)" not in help_text and "(default: [])" not in help_text
def test_epilog_keeps_line_breaks(help_text):
assert "\n backup ~/docs --to /mnt/backup\n" in help_text
def test_exclusive_verbosity_is_enforced(capsys):
with pytest.raises(SystemExit) as exc:
main(["src", "--to", "dst", "-v", "-q"])
assert exc.value.code == 2
assert "not allowed with argument" in capsys.readouterr().err
Setting COLUMNS makes wrapping deterministic; NO_COLOR keeps Python 3.14's coloured help out of the comparison. For a full snapshot of the help screen, the tooling in snapshot testing CLI output works unchanged.
Conclusion
argparse can produce help that reads as well as any framework's: group options by purpose with add_argument_group, give every option a meaningful metavar, put examples in an epilog, use RawDescriptionHelpFormatter with a small override that shows only informative defaults, and enable suggest_on_error on Python 3.14. Render help at a fixed width in tests, and it will stay readable as the CLI grows.
Frequently asked questions
Can I change the "options:" heading?
It is not configurable directly; the heading text changed from "optional arguments:" to "options:" in Python 3.10. If you need full control of headings, put every option in a named group, which leaves the built-in section with only -h, --help.
How do I put --help in a group too?
Create the parser with add_help=False and add -h/--help yourself with action="help" in the group of your choice.
Why do my newlines in help strings disappear?
The default formatter re-wraps all text. RawDescriptionHelpFormatter preserves the description and epilog; RawTextHelpFormatter preserves every help string, which means you must wrap them yourself.
Do argument groups work with subparsers?
Yes — each subparser is an ArgumentParser and has its own groups, formatter and epilog. Pass formatter_class=Formatter to each add_parser call, since subparsers do not inherit it.
Is it worth switching to Click or Typer just for nicer help?
Usually not. The techniques here close most of the gap, and argparse keeps its advantage of zero dependencies. Switch when you also want what the frameworks add beyond help — composable command groups, shared options, testing helpers — as weighed in argparse vs Click vs Typer.