Intermediate

You build a Click CLI tool, run it with --help, and get a wall of monochrome text. Option names blur together, required flags look identical to optional ones, and your carefully written docstring sits there as a grey blob. Every professional CLI tool — from pip to ruff to aws — has structured, colored, scannable help pages. Yours can too, and it takes one line of code to get started.

rich-click is a thin wrapper around Click that replaces its built-in help formatter with one powered by the rich library. You keep everything you already know about Click — decorators, commands, groups, options — and your help output gains panels, colored option tables, and structured sections without touching your business logic. Install it with pip install rich-click.

This article covers the full rich-click toolkit: drop-in replacement usage, option grouping with panels, command grouping in groups, theming and color configuration, epilog formatting with Markdown, and a real-life project that ties everything together. By the end, you will be able to ship CLI tools whose help pages look like they came from a professional software team.

rich-click: Quick Example

The fastest way to see what rich-click does is to swap one import. Here is a standard Click CLI and its rich-click equivalent side by side.

# quick_richclick.py
import rich_click as click

@click.command()
@click.option("--name", required=True, help="Your name.")
@click.option("--count", default=1, show_default=True, help="How many times to greet.")
@click.option("--verbose", is_flag=True, help="Enable verbose output.")
def greet(name, count, verbose):
    """Greet a user by name.

    Prints a greeting the specified number of times.
    Use --verbose to see extra detail.
    """
    for i in range(count):
        if verbose:
            click.echo(f"[{i+1}/{count}] Hello, {name}!")
        else:
            click.echo(f"Hello, {name}!")

if __name__ == "__main__":
    greet()

Output of python quick_richclick.py --help:

 Usage: quick_richclick.py [OPTIONS]

 Greet a user by name.

 Prints a greeting the specified number of times. Use --verbose to see extra
 detail.

+----------+------------------------------------------------------------------+
| Option   | Description                                                      |
+----------+------------------------------------------------------------------+
| --name   | Your name. [required]                                            |
| --count  | How many times to greet. [default: 1]                            |
| --verbose| Enable verbose output.                                           |
| --help   | Show this message and exit.                                      |
+----------+------------------------------------------------------------------+

The only change is import rich_click as click instead of import click. Your decorators, options, and function logic are completely unchanged. rich-click intercepts Click’s help rendering at the formatter level and replaces it with a rich-powered layout — the rest of your CLI works exactly as before.

The real power comes when you start grouping options and commands. Read on to learn how to build structured, professional help pages that guide users through complex CLIs.

What Is rich-click and Why Use It?

rich-click is an open-source library by Dominic Davis-Foster that wraps Click’s help formatter with the rich rendering engine. It was built to solve a specific, common problem: Click’s default help output is functionally correct but visually flat. When a CLI has 20 options across 5 subcommands, a flat alphabetical list becomes unreadable. Users scroll past relevant flags, miss required arguments, and give up.

The library takes a non-destructive approach. It does not fork Click or change its internals — it subclasses Click’s formatter and renderer, which means every Click feature (callback validation, command groups, option types, context settings) continues to work. You can also mix standard Click and rich-click in the same project during a gradual migration.

FeatureClick (default)rich-click
Help output stylePlain monochrome textColored panels and tables
Option groupingNot availableOPTION_GROUPS config
Command groupingNot availableCOMMAND_GROUPS config
Epilog formattingPlain text onlyRich Markdown
Color themesNot configurableFull color customization
Migration effortN/AOne import change
Runtime behaviorUnchangedUnchanged

Install both rich-click and rich (it is pulled in as a dependency automatically) with pip:

# In your terminal
pip install rich-click

Python 3.7+ and Click 7 or 8 are supported. No other setup is required.

Pyro Pete holds up a glowing terminal window with beautifully structured rich-click help output
pip install rich-click — the one upgrade every CLI deserves.

Grouping Options into Panels

The most impactful feature of rich-click is option grouping. Instead of one flat list of every flag, you can organize options into labeled panels that match how users think about the tool. A CLI with database options, output options, and auth options should look like three distinct sections, not a single alphabetical dump.

Option groups are configured via click.rich_click.OPTION_GROUPS, a dictionary that maps command names to a list of group definitions. Each group has a name and a list of options (the flag names as strings).

# option_groups.py
import rich_click as click

click.rich_click.OPTION_GROUPS = {
    "deploy": [
        {
            "name": "Connection",
            "options": ["--host", "--port", "--timeout"],
        },
        {
            "name": "Authentication",
            "options": ["--token", "--username", "--password"],
        },
        {
            "name": "Output",
            "options": ["--format", "--verbose", "--quiet"],
        },
    ]
}

@click.command()
@click.option("--host", default="localhost", show_default=True, help="Server hostname.")
@click.option("--port", default=8080, show_default=True, help="Server port.")
@click.option("--timeout", default=30, show_default=True, help="Connection timeout in seconds.")
@click.option("--token", help="API authentication token.")
@click.option("--username", help="Username for basic auth.")
@click.option("--password", help="Password for basic auth.")
@click.option("--format", type=click.Choice(["json", "table", "csv"]), default="table", show_default=True, help="Output format.")
@click.option("--verbose", is_flag=True, help="Enable verbose logging.")
@click.option("--quiet", is_flag=True, help="Suppress all output except errors.")
def deploy(host, port, timeout, token, username, password, format, verbose, quiet):
    """Deploy the application to a remote server."""
    click.echo(f"Deploying to {host}:{port} as {'token auth' if token else username}")

if __name__ == "__main__":
    deploy()

Output of python option_groups.py --help:

 Usage: deploy [OPTIONS]

 Deploy the application to a remote server.

 Connection
+-------------+-----------------------------------------------+
| --host      | Server hostname. [default: localhost]         |
| --port      | Server port. [default: 8080]                  |
| --timeout   | Connection timeout in seconds. [default: 30]  |
+-------------+-----------------------------------------------+

 Authentication
+-------------+-----------------------------------------------+
| --token     | API authentication token.                     |
| --username  | Username for basic auth.                      |
| --password  | Password for basic auth.                      |
+-------------+-----------------------------------------------+

 Output
+-------------+-----------------------------------------------+
| --format    | Output format. [default: table]               |
| --verbose   | Enable verbose logging.                       |
| --quiet     | Suppress all output except errors.            |
+-------------+-----------------------------------------------+

The key is that the keys in OPTION_GROUPS match the Click command name (the function name by default, or the name= argument in the decorator). Options not listed in any group fall through to a default “Other options” panel at the bottom, so you can migrate groups incrementally without losing any flags from the help page.

Grouping Subcommands in a Group CLI

If your CLI uses a command group (the pattern where mytool db migrate, mytool db rollback, and mytool server start are subcommands), rich-click can group those subcommands into labeled sections too. This uses click.rich_click.COMMAND_GROUPS with the same dictionary pattern.

# command_groups.py
import rich_click as click

click.rich_click.COMMAND_GROUPS = {
    "cli": [
        {
            "name": "Database",
            "commands": ["db-init", "db-migrate", "db-reset"],
        },
        {
            "name": "Server",
            "commands": ["server-start", "server-stop", "server-status"],
        },
    ]
}

@click.group()
def cli():
    """My Application CLI."""
    pass

@cli.command("db-init")
def db_init():
    """Initialize the database schema."""
    click.echo("Initializing DB...")

@cli.command("db-migrate")
@click.option("--steps", default=1, show_default=True, help="Number of migrations to apply.")
def db_migrate(steps):
    """Apply pending database migrations."""
    click.echo(f"Applying {steps} migration(s)...")

@cli.command("db-reset")
@click.option("--force", is_flag=True, help="Skip confirmation prompt.")
def db_reset(force):
    """Drop and recreate the database."""
    if not force:
        click.confirm("This will delete all data. Continue?", abort=True)
    click.echo("Resetting database...")

@cli.command("server-start")
@click.option("--port", default=8000, show_default=True, help="Port to listen on.")
def server_start(port):
    """Start the application server."""
    click.echo(f"Server running on port {port}...")

@cli.command("server-stop")
def server_stop():
    """Gracefully stop the server."""
    click.echo("Stopping server...")

@cli.command("server-status")
def server_status():
    """Show the server's current status."""
    click.echo("Server status: running")

if __name__ == "__main__":
    cli()

Output of python command_groups.py --help:

 Usage: cli [OPTIONS] COMMAND [ARGS]...

 My Application CLI.

 Database
+-------------+-------------------------------------------+
| db-init     | Initialize the database schema.           |
| db-migrate  | Apply pending database migrations.        |
| db-reset    | Drop and recreate the database.           |
+-------------+-------------------------------------------+

 Server
+-------------+-------------------------------------------+
| server-start | Start the application server.            |
| server-stop  | Gracefully stop the server.              |
| server-status| Show the server's current status.        |
+-------------+-------------------------------------------+

The command group name in COMMAND_GROUPS must match the Click group name. Subcommands listed in no group appear in a default “Commands” section — useful for utility commands like version or help that don’t belong in a domain group.

Sudo Sam at a whiteboard showing CLI help panels organized by section
Group your commands. Your users are not archaeologists.

Theming and Color Configuration

Beyond layout, rich-click exposes a configuration object that controls every color and style element in the help output. You set these as module-level assignments on click.rich_click before any commands are defined. The settings apply globally to all commands in that script.

# theming.py
import rich_click as click

# -- Theming configuration --
click.rich_click.STYLE_OPTION = "bold cyan"
click.rich_click.STYLE_SWITCH = "bold green"
click.rich_click.STYLE_METAVAR = "dim yellow"
click.rich_click.STYLE_HELPTEXT = "white"
click.rich_click.STYLE_HELPTEXT_FIRST_LINE = "bold white"
click.rich_click.STYLE_ERRORS_SUGGESTION = "italic dim"
click.rich_click.STYLE_OPTIONS_TABLE_BOX = "ROUNDED"
click.rich_click.MAX_WIDTH = 100
click.rich_click.SHOW_ARGUMENTS = True
click.rich_click.GROUP_ARGUMENTS_OPTIONS = True

@click.command()
@click.argument("filename")
@click.option("--output", "-o", help="Output file path.")
@click.option("--format", type=click.Choice(["json", "csv", "yaml"]), default="json", show_default=True, help="Output format.")
@click.option("--dry-run", is_flag=True, help="Preview changes without writing.")
def convert(filename, output, format, dry_run):
    """Convert FILENAME to another format.

    Reads the input file and writes a converted version.
    Supports JSON, CSV, and YAML output.
    """
    click.echo(f"Converting {filename} to {format}...")

if __name__ == "__main__":
    convert()

Output:

 Usage: theming.py [OPTIONS] FILENAME

 Convert FILENAME to another format.
 Reads the input file and writes a converted version. Supports JSON, CSV, and YAML output.

+------------+-----------------------------------------+
| Option     | Description                             |
+------------+-----------------------------------------+
| FILENAME   | (Required argument)                     |
| --output   | Output file path.                       |
| --format   | Output format. [default: json]          |
| --dry-run  | Preview changes without writing.        |
| --help     | Show this message and exit.             |
+------------+-----------------------------------------+

The STYLE_* settings accept any style string that rich understands: color names ("cyan", "red"), hex colors ("#ff6b6b"), and style modifiers ("bold", "dim", "italic"). The MAX_WIDTH setting caps the help panel width — useful if your terminal is very wide and you want the output to stay readable. SHOW_ARGUMENTS adds positional arguments to the options table alongside flags, so users see everything in one place.

Epilog Formatting with Markdown

Click supports an epilog parameter on commands — text that appears after the options table. By default, Click strips all formatting from it. With rich-click you can write the epilog as Markdown and have it rendered as rich text, complete with bold, links, and code spans.

# epilog_demo.py
import rich_click as click

click.rich_click.USE_MARKDOWN = True
click.rich_click.USE_MARKDOWN_EMOJI = False  # optional: disable emoji parsing

EPILOG = """
**Examples:**

Run a dry-run check:

    mytool check --dry-run input.csv

Convert with verbose output:

    mytool check --verbose --output result.json input.csv

See the full documentation at https://docs.example.com/mytool
"""

@click.command(epilog=EPILOG)
@click.argument("input_file")
@click.option("--output", "-o", default="output.json", show_default=True, help="Output file.")
@click.option("--dry-run", is_flag=True, help="Preview without writing.")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed progress.")
def check(input_file, output, dry_run, verbose):
    """Validate and convert INPUT_FILE.

    Reads a CSV or JSON input file, validates each row,
    and writes the cleaned output to the specified path.
    """
    click.echo(f"Checking {input_file}...")

if __name__ == "__main__":
    check()

Output (epilog section):

 Examples:

 Run a dry-run check:

   mytool check --dry-run input.csv

 Convert with verbose output:

   mytool check --verbose --output result.json input.csv

 See the full documentation at https://docs.example.com/mytool

Setting USE_MARKDOWN = True applies globally — every command’s help text and epilog will be parsed as Markdown. Code blocks in the epilog (fenced with backticks or indented by four spaces) render with syntax highlighting. This is ideal for “Examples” sections, which are one of the most read parts of any CLI help page.

Cache Katie speed-typing with a beautiful formatted terminal help page appearing on screen
USE_MARKDOWN = True. The epilog your users will actually read.

Real-Life Example: A File Conversion CLI

This project ties together option grouping, command grouping, theming, and Markdown epilog into a realistic multi-command CLI tool. It is a data file converter with two subcommands: convert and validate.

# file_converter_cli.py
import rich_click as click
import json
import csv
import os

# --- rich-click configuration ---
click.rich_click.USE_MARKDOWN = True
click.rich_click.MAX_WIDTH = 90
click.rich_click.SHOW_ARGUMENTS = True
click.rich_click.STYLE_OPTION = "bold cyan"
click.rich_click.STYLE_SWITCH = "bold green"

click.rich_click.OPTION_GROUPS = {
    "convert": [
        {
            "name": "Input / Output",
            "options": ["--output", "--format"],
        },
        {
            "name": "Behaviour",
            "options": ["--dry-run", "--verbose", "--skip-errors"],
        },
    ],
    "validate": [
        {
            "name": "Validation Rules",
            "options": ["--required", "--max-rows"],
        },
        {
            "name": "Reporting",
            "options": ["--report", "--verbose"],
        },
    ],
}

click.rich_click.COMMAND_GROUPS = {
    "fileconv": [
        {
            "name": "Data Commands",
            "commands": ["convert", "validate"],
        },
    ]
}

CONVERT_EPILOG = """
**Examples:**

Convert a JSON file to CSV:

    fileconv convert data.json --format csv --output data.csv

Dry-run to preview without writing:

    fileconv convert data.json --dry-run --verbose
"""

VALIDATE_EPILOG = """
**Examples:**

Validate a file with required columns:

    fileconv validate data.csv --required name --required email

Save a validation report:

    fileconv validate data.csv --report report.txt
"""

@click.group()
def fileconv():
    """File Conversion and Validation CLI.

    Convert between JSON and CSV formats and validate file structure.
    """
    pass

@fileconv.command(epilog=CONVERT_EPILOG)
@click.argument("input_file")
@click.option("--output", "-o", help="Output file path. Defaults to INPUT_FILE with new extension.")
@click.option("--format", "fmt", type=click.Choice(["json", "csv"]), required=True, help="Target format.")
@click.option("--dry-run", is_flag=True, help="Preview conversion without writing output.")
@click.option("--verbose", "-v", is_flag=True, help="Show each row as it is processed.")
@click.option("--skip-errors", is_flag=True, help="Continue on row errors instead of aborting.")
def convert(input_file, output, fmt, dry_run, verbose, skip_errors):
    """Convert INPUT_FILE to another format.

    Reads JSON or CSV and writes the converted file.
    Detects the input format from the file extension.
    """
    if not os.path.exists(input_file):
        raise click.BadParameter(f"File not found: {input_file}", param_hint="INPUT_FILE")

    base, _ = os.path.splitext(input_file)
    output = output or f"{base}.{fmt}"

    click.echo(f"Reading: {input_file}")

    try:
        if input_file.endswith(".json"):
            with open(input_file) as f:
                rows = json.load(f)
            if not isinstance(rows, list):
                raise click.ClickException("JSON input must be a list of objects.")
        elif input_file.endswith(".csv"):
            with open(input_file, newline="") as f:
                rows = list(csv.DictReader(f))
        else:
            raise click.ClickException("Unsupported input format. Use .json or .csv")
    except (json.JSONDecodeError, csv.Error) as e:
        raise click.ClickException(f"Failed to parse input: {e}")

    if verbose:
        for i, row in enumerate(rows, 1):
            click.echo(f"  Row {i}: {row}")

    if dry_run:
        click.echo(f"[dry-run] Would write {len(rows)} rows to: {output}")
        return

    if fmt == "csv":
        if not rows:
            raise click.ClickException("No rows to write.")
        with open(output, "w", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=rows[0].keys())
            writer.writeheader()
            writer.writerows(rows)
    else:
        with open(output, "w") as f:
            json.dump(rows, f, indent=2)

    click.echo(f"Written {len(rows)} rows to: {output}")

@fileconv.command(epilog=VALIDATE_EPILOG)
@click.argument("input_file")
@click.option("--required", multiple=True, help="Column that must be present and non-empty. Repeatable.")
@click.option("--max-rows", type=int, help="Fail if row count exceeds this limit.")
@click.option("--report", help="Write validation report to this file path.")
@click.option("--verbose", "-v", is_flag=True, help="List every issue found.")
def validate(input_file, required, max_rows, report, verbose):
    """Validate INPUT_FILE structure and contents.

    Checks for required columns, row count limits, and empty values.
    Exits with code 1 if any issues are found.
    """
    if not os.path.exists(input_file):
        raise click.BadParameter(f"File not found: {input_file}", param_hint="INPUT_FILE")

    with open(input_file, newline="") as f:
        rows = list(csv.DictReader(f))

    issues = []

    if max_rows and len(rows) > max_rows:
        issues.append(f"Row count {len(rows)} exceeds limit of {max_rows}.")

    for col in required:
        missing = [i+1 for i, r in enumerate(rows) if not r.get(col, "").strip()]
        if missing:
            issues.append(f"Column '{col}' is empty on rows: {missing[:5]}{'...' if len(missing) > 5 else ''}")

    summary = f"Validated {len(rows)} rows. Issues: {len(issues)}"
    click.echo(summary)

    if verbose:
        for issue in issues:
            click.echo(f"  - {issue}")

    if report:
        with open(report, "w") as f:
            f.write(summary + "\n")
            for issue in issues:
                f.write(f"- {issue}\n")
        click.echo(f"Report written to: {report}")

    if issues:
        raise click.ClickException(f"{len(issues)} validation issue(s) found.")

if __name__ == "__main__":
    fileconv()

Output of python file_converter_cli.py --help:

 Usage: fileconv [OPTIONS] COMMAND [ARGS]...

 File Conversion and Validation CLI.
 Convert between JSON and CSV formats and validate file structure.

 Data Commands
+-----------+------------------------------------------------+
| convert   | Convert INPUT_FILE to another format.          |
| validate  | Validate INPUT_FILE structure and contents.    |
+-----------+------------------------------------------------+

Output of python file_converter_cli.py convert --help:

 Usage: fileconv convert [OPTIONS] INPUT_FILE

 Convert INPUT_FILE to another format. Reads JSON or CSV and writes the converted file.

 Input / Output
+-----------+-----------------------------------------------------------+
| INPUT_FILE| (Required argument)                                       |
| --output  | Output file path. Defaults to INPUT_FILE with new ext.    |
| --format  | Target format. [required]                                 |
+-----------+-----------------------------------------------------------+

 Behaviour
+---------------+---------------------------------------------------+
| --dry-run     | Preview conversion without writing output.        |
| --verbose     | Show each row as it is processed.                 |
| --skip-errors | Continue on row errors instead of aborting.       |
+---------------+---------------------------------------------------+

 Examples:

 Convert a JSON file to CSV:

   fileconv convert data.json --format csv --output data.csv

This project shows the full rich-click pattern in a real tool. Notice how the two subcommands each have their own OPTION_GROUPS configuration — groups only appear on the command they are defined for, not globally. The epilog examples appear after the options table and use Markdown code blocks. You can extend this tool by adding a third subcommand (merge, split, or schema) and adding its group entry to OPTION_GROUPS.

Debug Dee comparing plain monochrome help text vs beautiful rich-click formatted panels
Before and after rich-click. One import. Night and day.

Frequently Asked Questions

Does rich-click change how my CLI actually runs?

No. rich-click only changes help page rendering. The command routing, argument parsing, option validation, and all callback logic are handled by Click as usual. You can verify this by calling your commands with real arguments — rich-click is not in the execution path at all, only in the --help path. If you encounter a behavioral difference, it is almost certainly a Click version incompatibility, not a rich-click issue.

Can I use rich-click on just some commands in a large project?

Yes. You can import rich_click as click in individual modules while keeping standard click imports elsewhere. Since rich-click is a superset of Click, the decorators are compatible. The configuration dict (OPTION_GROUPS, COMMAND_GROUPS) applies only to commands in the module that sets it — other modules using plain click are unaffected. This makes incremental migration safe in large codebases.

Will the colored output cause problems in CI pipelines or when piped to a file?

rich (and by extension rich-click) automatically detects whether the output stream is a terminal using is_tty checks. When output is redirected to a file or run in a CI environment without a TTY, all ANSI color codes are stripped automatically. The help text is still formatted (tables, panels) but without colors. You can force color off with NO_COLOR=1 environment variable, or force it on with FORCE_COLOR=1 for CI systems that support color (like GitHub Actions with GITHUB_ACTIONS=true).

What happens to options I forget to include in OPTION_GROUPS?

Any option not listed in an explicit group is collected into a default “Options” panel that appears at the bottom of the help output. This means you never accidentally hide an option by forgetting to group it — the worst case is that it appears ungrouped. The --help flag itself always appears in this fallback section unless you explicitly group it. This behavior makes it safe to add new options incrementally without updating OPTION_GROUPS immediately.

Should I use rich-click or Typer for a new project?

Use rich-click if you already have a Click codebase you want to improve, or if you prefer Click’s explicit decorator-based style. Use Typer if you are starting from scratch and want a fully type-annotated API where Click’s decorators are generated from function signatures automatically. Both use Click under the hood and both produce rich-formatted output — Typer uses rich natively, while rich-click layers it on top. Typer is a bigger dependency and a bigger migration; rich-click is a drop-in upgrade.

Conclusion

Python rich-click transforms Click help pages from a functional afterthought into a polished, navigable interface. The core tools covered in this article — the drop-in import swap, OPTION_GROUPS for panel-based option organization, COMMAND_GROUPS for structured command menus, STYLE_* settings for theming, and USE_MARKDOWN for rich epilog formatting — cover the full surface area most CLI tools need. All of this works without touching a single line of command logic.

The real-life project above shows the pattern at scale. Try extending it with a merge subcommand that combines two CSV files, or a schema command that infers a JSON Schema from the input data. Each new command gets its own OPTION_GROUPS entry and its own structured, readable help page.

For the full configuration reference — every STYLE_* variable, the USE_RICH_MARKUP flag, and the HEADER_TEXT / FOOTER_TEXT banner options — see the official rich-click documentation on GitHub and the Click documentation.