Click makes building CLIs easy. Here's how to use it effectively.

Basic Command

import click
 
@click.command()
@click.option("--name", default="World", help="Name to greet")
def hello(name):
    """Simple program that greets NAME."""
    click.echo(f"Hello, {name}!")
 
if __name__ == "__main__":
    hello()
$ python hello.py --name Owen
Hello, Owen!
 
$ python hello.py --help
Usage: hello.py [OPTIONS]
 
  Simple program that greets NAME.
 
Options:
  --name TEXT  Name to greet
  --help       Show this message and exit.

Options vs Arguments

Options are optional, named, and start with --:

@click.option("--count", default=1, help="Number of times")
@click.option("--verbose", is_flag=True, help="Enable verbose mode")

Arguments are required and positional:

@click.argument("filename")
@click.argument("output", required=False)

Common Option Patterns

# Required option
@click.option("--config", required=True)
 
# Multiple values
@click.option("--tag", multiple=True)
# Usage: --tag foo --tag bar
 
# Choice from list
@click.option("--format", type=click.Choice(["json", "csv", "table"]))
 
# File input/output
@click.option("--input", type=click.File("r"))
@click.option("--output", type=click.File("w"), default="-")
 
# Password (hidden input)
@click.option("--password", prompt=True, hide_input=True)
 
# Confirmation
@click.option("--yes", is_flag=True, help="Skip confirmation")

Command Groups

For CLIs with subcommands:

@click.group()
def cli():
    """Task management CLI."""
    pass
 
@cli.command()
@click.argument("name")
def add(name):
    """Add a new task."""
    click.echo(f"Added: {name}")
 
@cli.command()
@click.option("--all", is_flag=True)
def list(all):
    """List tasks."""
    click.echo("Listing tasks...")
 
@cli.command()
@click.argument("task_id")
def done(task_id):
    """Mark task as done."""
    click.echo(f"Completed: {task_id}")
 
if __name__ == "__main__":
    cli()
$ python tasks.py add "Write blog post"
Added: Write blog post
 
$ python tasks.py list --all
Listing tasks...
 
$ python tasks.py done 123
Completed: 123

Context and State

Share state between commands:

@click.group()
@click.option("--debug/--no-debug", default=False)
@click.pass_context
def cli(ctx, debug):
    ctx.ensure_object(dict)
    ctx.obj["DEBUG"] = debug
 
@cli.command()
@click.pass_context
def sync(ctx):
    if ctx.obj["DEBUG"]:
        click.echo("Debug mode enabled")
    click.echo("Syncing...")

Output Formatting

# Colored output
click.echo(click.style("Success!", fg="green", bold=True))
click.echo(click.style("Error!", fg="red"))
 
# Shorthand
click.secho("Success!", fg="green", bold=True)
 
# Progress bar
with click.progressbar(items) as bar:
    for item in bar:
        process(item)
 
# Confirmation prompt
if click.confirm("Delete all files?"):
    delete_all()

Error Handling

@click.command()
@click.argument("filename")
def process(filename):
    try:
        with open(filename) as f:
            data = f.read()
    except FileNotFoundError:
        raise click.ClickException(f"File not found: {filename}")
    except PermissionError:
        raise click.ClickException(f"Permission denied: {filename}")

Exit codes:

import sys
 
@click.command()
def check():
    if something_wrong():
        click.echo("Check failed", err=True)
        sys.exit(1)
    click.echo("Check passed")

Testing

Click has built-in testing support:

from click.testing import CliRunner
 
def test_hello():
    runner = CliRunner()
    result = runner.invoke(hello, ["--name", "Test"])
    assert result.exit_code == 0
    assert "Hello, Test!" in result.output
 
def test_missing_required():
    runner = CliRunner()
    result = runner.invoke(cli, ["add"])  # Missing argument
    assert result.exit_code != 0

Entry Points

Install your CLI with pip:

# pyproject.toml
[project.scripts]
mytool = "mypackage.cli:cli"

After pip install .:

$ mytool add "Task"
$ mytool list

My Patterns

Always add help text:

@click.option("--output", "-o", help="Output file path")

Use sensible defaults:

@click.option("--format", default="table", show_default=True)

Fail fast with clear errors:

if not path.exists():
    raise click.ClickException(f"Path does not exist: {path}")

Support both interactive and scriptable use:

@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def delete(yes):
    if not yes and not click.confirm("Delete?"):
        raise click.Abort()

Click handles the boring parts. You focus on the logic.

React to this post: