Architecture

Argument Groups and Help Formatting in argparse

Make argparse help readable: argument groups with descriptions, metavars, examples in the epilog, a formatter that shows only useful defaults, and help snapshot tests.

Updated

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

Anatomy of a readable help screen The parts of a well-structured argparse help screen, in order, and the argparse feature that produces each. Anatomy of a readable help screen Usage line automatic generated from your arguments; metavars make it readable Description description= one sentence on what the command does Option groups add_argument_group destination, filtering, output — each with a heading Informative defaults formatter shown only when they say something Examples epilog= copy-pasteable, line breaks preserved Every part is standard-library argparse; one needs a small formatter subclass.

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
Grouped help and a typo suggestion Terminal session showing part of a grouped argparse help screen and a Python 3.14 did-you-mean error for an invalid choice. Grouped help and a typo suggestion bash $ backup --help | sed -n "/destination:/,/filtering:/p" destination: --to DEST local path or s3:// URL --storage-class {STANDARD,GLACIER} S3 storage class (default: STANDARD) $ backup ~/docs --to /mnt --storage-class GLACER backup: error: … invalid choice: 'GLACER', maybe you meant 'GLACIER'? suggest_on_error needs Python 3.14; guard it with a version check.

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.SUPPRESS rather 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.
argparse help, done well Practices for readable argparse help output and common mistakes. argparse help, done well Do ✓ Group options by purpose ✓ Metavars with units: MB, SECONDS ✓ Examples in the epilog ✓ Render at fixed COLUMNS in tests Avoid ✗ (default: None) on every option ✗ Auto metavars like MAX_SIZE ✗ Re-wrapped example blocks ✗ Raw {a,b,c} choice lists for commands Help is user-facing output — snapshot it like any other.

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.