click
Click is the Pallets project's toolkit for building Python command line interfaces out of decorated functions. You stack @click.command, @click.option, and @click.argument on a plain function and Click handles parsing, validation, help page generation, prompts, and error messages. Commands nest arbitrarily into groups, subcommands can load lazily at runtime, and utilities like progress bars, pagers, and colored echo are included. It is the CLI layer under Flask's own command line and a large share of Python tools.
The default choice for any Python CLI beyond a trivial script, proven by roughly 272M weekly downloads and years of API stability. Reach for stdlib argparse below its complexity floor and Typer if you want type-hint ergonomics on the same engine.
Use it if
- You are building a multi-command tool (like git's verb style) and want nesting, shared context, and auto-generated help for free
- You need interactive touches such as password prompts, yes/no confirmations, or progress bars without extra dependencies
- You want your CLI to be testable: CliRunner invokes commands in-process and captures output and exit codes
- You value sensible defaults over configuration and want the CLI done in decorators next to the logic
- Your script takes two or three flags: argparse ships with Python and costs you zero dependencies
- You want the interface derived from type hints instead of decorator options: Typer builds exactly that on top of Click
- You dislike decorator indirection: parameter names are inferred from option strings and the call flow is hidden, which makes some debugging and programmatic invocation awkward
- You must support Python older than 3.10, which current Click releases have dropped
Setup reality
pip install click is instant, pure Python, no compiled pieces. The friction is conceptual: decorators apply bottom-up so option order matters relative to the command decorator, parameter names are derived from the longest option string with dashes turned into underscores, and forgetting that mapping is the classic first bug. Calling a command from other Python code hits standalone_mode, which raises SystemExit unless you disable it. Prompts and colors behave differently when output is piped, and testing anything interactive means learning CliRunner's input parameter rather than patching stdin.
Patterns
A first command with optionsbasic-command
import click
@click.command()
@click.option("--count", default=1, help="Number of greetings.")
@click.option("--name", prompt="Your name", help="The person to greet.")
def hello(count, name):
"""Simple program that greets NAME for a total of COUNT times."""
for _ in range(count):
click.echo(f"Hello, {name}!")
if __name__ == "__main__":
hello()The function docstring becomes the help text; click.echo instead of print handles encoding and piping edge cases.
Take positional argumentspositional-arguments
import click
@click.command()
@click.argument("src")
@click.argument("dsts", nargs=-1)
def copy(src, dsts):
for dst in dsts:
click.echo(f"{src} -> {dst}")Arguments get no help= parameter by design; document them in the command docstring. nargs=-1 accepts zero or more values.
Group subcommands like gitsubcommands
import click
@click.group()
@click.option("--verbose", is_flag=True)
@click.pass_context
def cli(ctx, verbose):
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
@cli.command()
@click.pass_context
def deploy(ctx):
if ctx.obj["verbose"]:
click.echo("deploying loudly")
if __name__ == "__main__":
cli()Group options go before the subcommand on the command line: tool --verbose deploy, not tool deploy --verbose.
Boolean and paired flagsboolean-flag
@click.command()
@click.option("--dry-run", is_flag=True)
@click.option("--color/--no-color", default=True)
def build(dry_run, color):
click.echo(f"dry_run={dry_run} color={color}")The option --dry-run arrives as the parameter dry_run; dashes always become underscores in the function signature.
Restrict values with Choicechoice-option
@click.command()
@click.option(
"--env",
type=click.Choice(["dev", "staging", "prod"], case_sensitive=False),
default="dev",
show_default=True,
)
def deploy(env):
click.echo(f"deploying to {env}")Invalid values fail with a usage error before your function runs; show_default=True surfaces the default in --help.
Prompt for a password safelypassword-prompt
@click.command()
@click.option(
"--password",
prompt=True,
hide_input=True,
confirmation_prompt=True,
envvar="APP_PASSWORD",
)
def login(password):
click.echo("authenticated")With envvar set, a present environment variable skips the prompt entirely, which is what you want in CI.
Confirm before a destructive actionconfirm-destructive
@click.command()
def dropdb():
click.confirm("Drop all tables?", abort=True)
click.echo("Dropped.")abort=True raises Abort on "no", exiting with a non-zero code; there is also a @click.confirmation_option decorator for a --yes flag.
Validate file paths at parse timevalidate-paths
@click.command()
@click.argument(
"config",
type=click.Path(exists=True, dir_okay=False, readable=True),
)
@click.argument("out", type=click.File("w"))
def render(config, out):
out.write(open(config).read())click.File("-") style values map to stdin/stdout automatically; click.Path hands you a string (or pathlib.Path with path_type=Path).
Show a progress bar over an iterableprogress-bar
import time
import click
@click.command()
def sync():
items = range(200)
with click.progressbar(items, label="Syncing") as bar:
for _ in bar:
time.sleep(0.01)The bar only renders on a real terminal; when output is piped it degrades to silence rather than garbage.
Fail with a proper exit codeexit-codes
import click
@click.command()
@click.argument("name")
def greet(name):
if not name.isalpha():
raise click.BadParameter("name must be letters only")
if name == "root":
raise click.ClickException("refusing to greet root")
click.echo(f"hi {name}")ClickException prints "Error: ..." and exits 1; raising SystemExit yourself skips Click's formatting and error handling.
Test a command with CliRunnertest-cli
from click.testing import CliRunner
from mytool import hello
def test_hello():
runner = CliRunner()
result = runner.invoke(hello, ["--count", "2"], input="Ada\n")
assert result.exit_code == 0
assert result.output.count("Hello, Ada!") == 2invoke catches exceptions by default; check result.exception (or pass catch_exceptions=False) or failures show up only as exit_code 1.
Load heavy subcommands lazilylazy-subcommands
import importlib
import click
class LazyGroup(click.Group):
def __init__(self, *args, lazy_subcommands=None, **kwargs):
super().__init__(*args, **kwargs)
self.lazy = lazy_subcommands or {}
def list_commands(self, ctx):
return sorted(super().list_commands(ctx) + list(self.lazy))
def get_command(self, ctx, name):
if name in self.lazy:
mod_name, cmd_name = self.lazy[name].rsplit(".", 1)
return getattr(importlib.import_module(mod_name), cmd_name)
return super().get_command(ctx, name)
@click.group(cls=LazyGroup, lazy_subcommands={"train": "mytool.ml.train"})
def cli():
passThis is the documented pattern for keeping --help fast when subcommands import slow libraries like pandas or torch.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| typer | PyPI | You want the same power with the interface generated from function type hints; it is built on Click. |
| fire | PyPI | You want a CLI thrown over any existing class or module in one line and do not care about polished help text. |
| docopt | PyPI | You prefer writing the help text first and having the parser derived from it, for small stable tools. |