Sooner or later, every Python project grows a command-line entry point. A data engineer needs a way to kick off a backfill from the terminal. A library author wants mytool --version. A team has three scripts that all want shared flags. The pattern is so consistent that there are three well-established libraries for it, and the one you pick depends mostly on how much you care about ergonomics and how big the surface area is going to get.
This lesson is the three options — argparse, click, typer — written side-by-side so the trade-offs are visible. Then a slightly bigger example, then how to ship the result as a real installable command.
argparse: stdlib, no dependencies
argparse is in the standard library. That’s its main selling point: zero install, available everywhere. The cost is a verbose, declarative API that feels heavier than it should for what you’re doing.
# greet_argparse.py
import argparse
def main() -> None:
parser = argparse.ArgumentParser(
prog="greet",
description="Greet a person by name.",
)
parser.add_argument("name", help="who to greet")
parser.add_argument("--greeting", default="Hello", help="the greeting word")
parser.add_argument("--count", type=int, default=1, help="how many times")
parser.add_argument("--shout", action="store_true", help="uppercase the output")
args: argparse.Namespace = parser.parse_args()
line: str = f"{args.greeting}, {args.name}!"
if args.shout:
line = line.upper()
for _ in range(args.count):
print(line)
if __name__ == "__main__":
main()
$ python greet_argparse.py Marco --greeting Ciao --count 2 --shout
CIAO, MARCO!
CIAO, MARCO!
Things to notice:
- Each
add_argumentis a method call with positional + keyword args. Verbose but explicit. argsis aNamespaceobject —args.name,args.greeting, etc. — not type-checked. The type hints in your function won’t help here.--helpis generated automatically. That’s the one place argparse is genuinely good.
When argparse is fine: a single script with five flags or fewer, and you don’t want a dependency. For anything more, the productivity drop relative to click/typer is real.
click: decorator-based, the workhorse
click is a third-party package (pip install click) that’s been the de-facto Python CLI library for over a decade. The pattern is decorator-based, the abstractions are mature, and a huge fraction of the Python ecosystem is built on it. pip itself is a click application.
# greet_click.py
import click
@click.command()
@click.argument("name")
@click.option("--greeting", default="Hello", help="the greeting word")
@click.option("--count", default=1, type=int, help="how many times")
@click.option("--shout", is_flag=True, help="uppercase the output")
def main(name: str, greeting: str, count: int, shout: bool) -> None:
"""Greet a person by name."""
line: str = f"{greeting}, {name}!"
if shout:
line = line.upper()
for _ in range(count):
click.echo(line)
if __name__ == "__main__":
main()
Same tool, different shape:
- The function signature is the CLI definition. Each
@click.optionand@click.argumentadds a parameter. - The function takes normal Python arguments, not a Namespace. They flow into your code with the names and types you declared.
click.echoinstead ofprint— the only practical difference is that it handles encoding edge cases and respects test runners that capture output.
The big click win is subcommands. Real CLIs are rarely single commands; they’re git commit, git push, git status. Click’s @click.group makes this clean:
import click
@click.group()
def cli() -> None:
"""Mytool: do things."""
@cli.command()
@click.option("--port", default=8000)
def serve(port: int) -> None:
"""Run the server."""
click.echo(f"serving on port {port}")
@cli.command()
@click.argument("target")
def migrate(target: str) -> None:
"""Apply migrations up to TARGET."""
click.echo(f"migrating to {target}")
@cli.command()
def status() -> None:
"""Show current status."""
click.echo("everything is fine")
if __name__ == "__main__":
cli()
$ python mytool.py --help
$ python mytool.py serve --port 9000
$ python mytool.py migrate v3
$ python mytool.py status
Each subcommand has its own --help, its own arguments, its own logic. Groups can be nested: a cli group containing a db group containing migrate, dump, restore subcommands.
For mid-sized to large CLIs, click is the safe choice. It’s stable, well-documented, well-supported, and the patterns are predictable.
typer: type-hint-driven, the modern pick
typer (pip install typer) is built on top of click. The thesis is simple: if you’re already writing type hints, the CLI library should read them and skip the decorator boilerplate.
# greet_typer.py
from typing import Annotated
import typer
app = typer.Typer()
@app.command()
def main(
name: Annotated[str, typer.Argument(help="who to greet")],
greeting: Annotated[str, typer.Option(help="the greeting word")] = "Hello",
count: Annotated[int, typer.Option(help="how many times")] = 1,
shout: Annotated[bool, typer.Option(help="uppercase the output")] = False,
) -> None:
"""Greet a person by name."""
line: str = f"{greeting}, {name}!"
if shout:
line = line.upper()
for _ in range(count):
typer.echo(line)
if __name__ == "__main__":
app()
A few things to notice:
- The type hints (
int,bool,str) drive parsing. You don’t writetype=int; typer reads the annotation. Annotated[T, typer.Option(...)]attaches CLI metadata without polluting the runtime type. This is the modern style; older typer code usesname: str = typer.Argument(...)as the default value, which works but is less clean.- Defaults in the signature become defaults in the CLI.
- The docstring becomes the
--helpdescription automatically.
Subcommands work the same way as click groups, just with @app.command():
import typer
app = typer.Typer()
@app.command()
def serve(port: int = 8000) -> None:
"""Run the server."""
typer.echo(f"serving on port {port}")
@app.command()
def migrate(target: str) -> None:
"""Apply migrations up to TARGET."""
typer.echo(f"migrating to {target}")
@app.command()
def status() -> None:
"""Show current status."""
typer.echo("everything is fine")
if __name__ == "__main__":
app()
The typer pitch is that there’s almost no CLI-specific code — the function is the function, the types are the parsing, the docstring is the help. In a codebase where everything else is type-hinted, typer is the option that fits the style. In 2026, most new Python CLIs default to it.
Side-by-side: same tool, three libraries
The greeting tool above appears in all three flavors. Counting non-import, non-comment lines of CLI scaffolding:
- argparse: ~7 lines of
add_argumentplus the parse + namespace handling. - click: 3 decorator lines plus a function signature.
- typer: a function signature, period.
For a five-flag tool the difference is small. For a fifty-flag tool with five subcommands, the decorator/type-hint approaches scale dramatically better.
When to pick which:
- argparse — you genuinely cannot take a dependency (Python distribution tooling, a one-off script in a constrained environment), or the CLI is so trivial that the import cost beats the productivity gain.
- click — you want a mature, stable, battle-tested library; you’re working on a codebase that already uses click; you need a feature typer hasn’t exposed yet.
- typer — anything new, especially type-hint-heavy code, especially data engineering and ML projects.
Auto-generated help
All three give you --help for free. The quality differs:
- argparse: functional, occasionally awkward formatting.
- click: polished, supports rich help panels, integrates with
click-help-colorsfor color. - typer: built on click, plus pulls help text directly from docstrings and
Annotatedmetadata. Pairs natively withrichfor color and tables.
# typer + rich, the typical 2026 pairing
import typer
from rich.console import Console
app = typer.Typer(rich_markup_mode="rich")
console = Console()
@app.command()
def report(name: str) -> None:
"""Print a [bold green]styled[/bold green] greeting."""
console.print(f"Hello, [cyan]{name}[/cyan]!")
rich_markup_mode="rich" turns docstring tags into colored output. The rich library on its own gives you tables, progress bars, syntax highlighting, and tracebacks. For any CLI a human will look at, the typer + rich combination is the current default.
AI assistance note. Coding assistants are unusually good at scaffolding new CLIs. The patterns are so structured — declare flags, parse, dispatch — that one or two prompts produce a working tool. The reliable failure mode is the opposite: they tend to over-engineer. You ask for a single-command tool with three flags and you get back a four-subcommand framework with
init,config,validate, and a--verboseflag wired through every level. Read what you got, delete what you don’t need, keep the parts that match the actual scope. CLIs are easy to grow later; pre-emptive complexity is what you can’t easily remove.
Case study: how uv structures its CLI
uv is the Rust-backed Python package manager that’s largely replaced pip and pip-tools for new projects in the last two years. It’s not written in Python, but its command structure is a useful reference because the same shape works equally well with click or typer.
The top level is a group, with commands organized by concern:
uv pip install,uv pip compile,uv pip sync— pip-compatible operations under apipsubgroup.uv add,uv remove,uv lock,uv sync— project-level operations at the top level.uv run,uv tool run— execution.uv python install,uv python list— interpreter management.
The lesson isn’t the specific commands; it’s the grouping. Subcommands cluster by concern, and the most common operations live at the top level for fewer keystrokes. In typer that maps to:
app = typer.Typer()
pip_app = typer.Typer()
python_app = typer.Typer()
tool_app = typer.Typer()
app.add_typer(pip_app, name="pip")
app.add_typer(python_app, name="python")
app.add_typer(tool_app, name="tool")
@app.command()
def add(package: str) -> None: ...
@app.command()
def sync() -> None: ...
@pip_app.command("install")
def pip_install(package: str) -> None: ...
@pip_app.command("compile")
def pip_compile(spec: str) -> None: ...
The add_typer call mounts a sub-application under a name. From the user’s perspective, mytool pip install foo is one command; from the code’s perspective, it’s a function in a sub-app.
Packaging as a console script
The last step. Right now your tool runs as python mytool.py args. Real CLIs run as mytool args. The trick is the console_scripts entry point.
In pyproject.toml:
[project]
name = "mytool"
version = "0.1.0"
dependencies = ["typer", "rich"]
[project.scripts]
mytool = "mytool.cli:app"
The line mytool = "mytool.cli:app" says “create an executable called mytool that calls app from the mytool.cli module.” The app here is the typer application object (or for click, the group function). At install time, pip install . (or uv pip install ., or uv tool install . for a global install) generates a small launcher script in your environment’s bin directory, and mytool becomes a real command.
For a project meant to be installed globally without adding to the user’s environment, uv tool install (or the older pipx install) is the right pattern — it sets up an isolated venv per tool and exposes the executable on $PATH.
A reasonable starting template
For a new CLI today, the default skeleton looks like this:
# src/mytool/cli.py
from typing import Annotated
import logging
import typer
from rich.console import Console
from rich.logging import RichHandler
app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich")
console = Console()
def _setup_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(message)s",
handlers=[RichHandler(console=console, rich_tracebacks=True)],
)
@app.callback()
def main(
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="enable debug logging")] = False,
) -> None:
"""Mytool: do things."""
_setup_logging(verbose)
@app.command()
def serve(
port: Annotated[int, typer.Option(help="port to bind")] = 8000,
) -> None:
"""Run the server."""
logging.getLogger(__name__).info("serving on port %d", port)
@app.command()
def status() -> None:
"""Show current status."""
console.print("[green]everything is fine[/green]")
if __name__ == "__main__":
app()
What this gives you:
- A typer app with two subcommands.
- A top-level
--verboseflag handled in the@app.callback()— this runs before any subcommand. - Logging via
RichHandlerso log lines and tracebacks are colored and aligned. no_args_is_help=Trueso runningmytoolwith nothing prints the help instead of exiting silently.
Add a [project.scripts] entry, pip install ., and mytool serve --port 9000 works.
That’s the modern pattern. argparse is the lowest-dependency option, click is the established workhorse, typer is where new code is going. Whichever you pick: keep the surface small, let --help do the documentation work, log instead of print, and ship it as a console script the moment more than one person uses it.
References: argparse — Parser for command-line options, click documentation, typer documentation, rich documentation, Python Packaging User Guide — Entry points, uv. Retrieved 2026-05-01.