Python CLI Tools with Click and Typer: Complete Guide 2026
Every developer builds command-line tools. Whether it is a deployment script, a data pipeline, or an internal utility, a well-designed CLI is faster and more composable than any GUI. Python has three main approaches: the stdlib argparse, the decorator-based Click, and the type-hint-driven Typer. This guide covers all three, with the bulk of focus on Click and Typer — the two libraries that make building professional CLIs genuinely pleasant.
Table of Contents
- Why CLI Tools Matter
- argparse Quick Overview
- Click: Basic Commands
- Click: Groups and Subcommands
- Click: Prompts and Confirmation
- Click: File Handling and Piping
- Click: Custom Types and Validation
- Click: Colorful Output
- Click: Testing with CliRunner
- Typer: Introduction
- Typer: Type Hints as Parameters
- Typer: Subcommands and Composition
- Typer: Rich Integration
- Typer: Testing
- Click vs Typer Comparison
- Packaging and Distribution
- Real-World Patterns
- FAQ
1. Why CLI Tools Matter
CLI tools are composable: you pipe them together, script them in shell, run them in CI, and automate them with cron. A good CLI has clear help text, predictable exit codes, and sensible defaults. Python is ideal for CLIs because of its readable syntax, rich standard library, and massive package ecosystem. The question is which framework to use.
argparse ships with Python but requires verbose, imperative code. Click uses decorators for a declarative API with excellent composability. Typer builds on Click and uses type hints to eliminate boilerplate entirely. All three produce the same thing: an executable that parses arguments, runs logic, and returns an exit code.
2. argparse Quick Overview
The stdlib argparse works but is verbose. Here is a minimal example for comparison:
import argparse
def main():
parser = argparse.ArgumentParser(description="Greet a user")
parser.add_argument("name", help="Name to greet")
parser.add_argument("--greeting", default="Hello", help="Greeting word")
parser.add_argument("--shout", action="store_true", help="Uppercase output")
args = parser.parse_args()
message = f"{args.greeting}, {args.name}!"
if args.shout:
message = message.upper()
print(message)
if __name__ == "__main__":
main()
This works fine for simple scripts. For anything with subcommands, validation, file I/O, or testing, Click and Typer are significantly better. The rest of this guide focuses on them.
3. Click: Basic Commands
Click uses decorators to define commands. Install it and write your first CLI:
pip install click
import click
@click.command()
@click.argument("name")
@click.option("--greeting", "-g", default="Hello", help="Greeting word.")
@click.option("--shout", is_flag=True, help="Uppercase the output.")
def greet(name, greeting, shout):
"""Greet NAME with a friendly message."""
message = f"{greeting}, {name}!"
if shout:
message = message.upper()
click.echo(message)
if __name__ == "__main__":
greet()
Arguments are positional and required by default. Options are named flags with -- prefix. The docstring becomes the command help text. click.echo() handles encoding and piping better than print().
$ python greet.py Alice --greeting Hi --shout
HI, ALICE!
$ python greet.py --help
Usage: greet.py [OPTIONS] NAME
Greet NAME with a friendly message.
Options:
-g, --greeting TEXT Greeting word.
--shout Uppercase the output.
--help Show this message and exit.
Options support types, defaults, multiple values, and choices:
@click.command()
@click.option("--count", "-c", type=int, default=1, help="Repeat count.")
@click.option("--color", type=click.Choice(["red", "green", "blue"]))
@click.option("--verbose", "-v", count=True, help="Verbosity level (-vvv).")
@click.option("--tag", multiple=True, help="Tags (repeatable).")
def process(count, color, verbose, tag):
"""Process with options."""
click.echo(f"Count: {count}, Color: {color}")
click.echo(f"Verbosity: {verbose}, Tags: {tag}")
4. Click: Groups and Subcommands
Real CLIs have subcommands: git commit, docker run, kubectl apply. Click uses @click.group() to create command groups:
import click
@click.group()
@click.option("--debug/--no-debug", default=False)
@click.pass_context
def cli(ctx, debug):
"""My application CLI."""
ctx.ensure_object(dict)
ctx.obj["DEBUG"] = debug
@cli.command()
@click.argument("name")
@click.pass_context
def create(ctx, name):
"""Create a new resource."""
debug = ctx.obj["DEBUG"]
click.echo(f"Creating {name} (debug={debug})")
@cli.command()
@click.argument("name")
@click.option("--force", is_flag=True, help="Skip confirmation.")
def delete(name, force):
"""Delete a resource."""
if not force:
click.confirm(f"Delete {name}?", abort=True)
click.echo(f"Deleted {name}")
if __name__ == "__main__":
cli()
$ python app.py --debug create my-project
Creating my-project (debug=True)
$ python app.py delete my-project --force
Deleted my-project
Nest groups for deeper hierarchies: cli.add_command(sub_group). The ctx.obj dictionary passes shared state from parent to child commands via @click.pass_context.
5. Click: Prompts and Confirmation
Click handles interactive input cleanly, including hidden password prompts:
@click.command()
@click.option("--name", prompt="Your name", help="User name.")
@click.option("--password", prompt=True, hide_input=True,
confirmation_prompt=True, help="Set password.")
def register(name, password):
"""Register a new user."""
click.echo(f"Registered {name} (password length: {len(password)})")
@click.command()
def deploy():
"""Deploy to production."""
click.confirm("Deploy to production?", abort=True)
with click.progressbar(range(100), label="Deploying") as bar:
for _ in bar:
import time; time.sleep(0.02)
click.echo("Deployed!")
If the option is passed on the command line, the prompt is skipped. This makes commands work in both interactive and scripted contexts.
6. Click: File Handling and Piping
Click has built-in file types that handle encoding, stdin/stdout, and lazy opening:
@click.command()
@click.argument("input", type=click.File("r"), default="-")
@click.argument("output", type=click.File("w"), default="-")
def uppercase(input, output):
"""Convert input to uppercase. Reads stdin if no file given."""
for line in input:
output.write(line.upper())
# Usage:
# python upper.py input.txt output.txt
# cat input.txt | python upper.py > output.txt
# echo "hello" | python upper.py
Use click.Path() when you need a path string rather than an open file handle:
@click.command()
@click.argument("src", type=click.Path(exists=True, dir_okay=False))
@click.argument("dst", type=click.Path(writable=True))
def copy(src, dst):
"""Copy SRC file to DST."""
import shutil
shutil.copy2(src, dst)
click.echo(f"Copied {src} -> {dst}")
7. Click: Custom Types and Validation
Create custom parameter types by subclassing click.ParamType:
class DateType(click.ParamType):
name = "date"
def convert(self, value, param, ctx):
if isinstance(value, datetime.date):
return value
try:
return datetime.datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
self.fail(f"'{value}' is not a valid date (YYYY-MM-DD)", param, ctx)
DATE = DateType()
@click.command()
@click.option("--start", type=DATE, required=True, help="Start date (YYYY-MM-DD).")
@click.option("--end", type=DATE, default=str(datetime.date.today()))
def report(start, end):
"""Generate report for date range."""
click.echo(f"Report: {start} to {end}")
Use callbacks for option-level validation:
def validate_port(ctx, param, value):
if value < 1 or value > 65535:
raise click.BadParameter("Port must be 1-65535.")
return value
@click.command()
@click.option("--port", type=int, default=8080, callback=validate_port)
def serve(port):
click.echo(f"Serving on port {port}")
8. Click: Colorful Output
Use click.style() and click.secho() for colored terminal output:
@click.command()
@click.argument("status", type=click.Choice(["ok", "warn", "error"]))
def check(status):
"""Check system status."""
colors = {"ok": "green", "warn": "yellow", "error": "red"}
icons = {"ok": "[PASS]", "warn": "[WARN]", "error": "[FAIL]"}
click.secho(f"{icons[status]} System is {status}", fg=colors[status], bold=True)
# Or build styled strings:
label = click.style("Status:", fg="cyan", bold=True)
value = click.style(status, fg=colors[status])
click.echo(f"{label} {value}")
Colors degrade gracefully — when output is piped, ANSI codes are stripped automatically.
9. Click: Testing with CliRunner
Click provides CliRunner for testing without subprocess overhead:
from click.testing import CliRunner
def test_greet():
runner = CliRunner()
result = runner.invoke(greet, ["Alice", "--greeting", "Hi"])
assert result.exit_code == 0
assert "Hi, Alice!" in result.output
def test_greet_shout():
runner = CliRunner()
result = runner.invoke(greet, ["Bob", "--shout"])
assert "HELLO, BOB!" in result.output
def test_greet_missing_name():
runner = CliRunner()
result = runner.invoke(greet, [])
assert result.exit_code != 0
assert "Missing argument" in result.output
def test_file_command():
runner = CliRunner()
with runner.isolated_filesystem():
with open("input.txt", "w") as f:
f.write("hello world")
result = runner.invoke(uppercase, ["input.txt", "output.txt"])
assert result.exit_code == 0
with open("output.txt") as f:
assert f.read() == "HELLO WORLD"
runner.invoke() captures output, exit code, and exceptions. isolated_filesystem() creates a temporary directory for file tests.
10. Typer: What It Is and Why It Exists
Typer is built on top of Click by the creator of FastAPI. Instead of decorators for every parameter, Typer reads your function signature — type hints, defaults, and docstrings become the CLI interface automatically.
pip install typer[all] # includes rich and shellingham
import typer
app = typer.Typer()
@app.command()
def greet(
name: str,
greeting: str = typer.Option("Hello", "--greeting", "-g", help="Greeting word."),
shout: bool = typer.Option(False, "--shout", help="Uppercase output."),
):
"""Greet NAME with a friendly message."""
message = f"{greeting}, {name}!"
if shout:
message = message.upper()
typer.echo(message)
if __name__ == "__main__":
app()
The output and --help are identical to Click, but notice: no @click.argument or @click.option decorators. The type hints do the work.
11. Typer: Type Hints as Parameters
Typer infers parameter behavior from types and defaults:
from typing import Optional
from enum import Enum
import typer
class Color(str, Enum):
red = "red"
green = "green"
blue = "blue"
@app.command()
def paint(
name: str = typer.Argument(..., help="Item to paint."),
color: Color = typer.Option(Color.red, help="Paint color."),
coats: int = typer.Option(2, min=1, max=10, help="Number of coats."),
dry: bool = typer.Option(False, "--dry/--wet", help="Dry or wet paint."),
notes: Optional[str] = typer.Option(None, help="Optional notes."),
):
"""Paint an item with the specified color."""
typer.echo(f"Painting {name} {color.value} x{coats} (dry={dry})")
if notes:
typer.echo(f"Notes: {notes}")
Enums become Choice parameters automatically. Optional[str] makes a parameter optional. bool with --flag/--no-flag syntax creates toggle flags. int with min/max adds validation.
Typer also provides automatic shell completion. Run myapp --install-completion to install tab completion for bash, zsh, fish, or PowerShell.
12. Typer: Subcommands and Composition
Create subcommands by adding multiple commands to a Typer() app, or compose apps together:
import typer
app = typer.Typer(help="Project management CLI.")
users_app = typer.Typer(help="Manage users.")
app.add_typer(users_app, name="users")
@app.command()
def init(name: str = typer.Argument(..., help="Project name.")):
"""Initialize a new project."""
typer.echo(f"Initialized project: {name}")
@users_app.command("list")
def list_users():
"""List all users."""
typer.echo("alice\nbob\ncharlie")
@users_app.command("add")
def add_user(
name: str,
role: str = typer.Option("member", help="User role."),
):
"""Add a user to the project."""
typer.echo(f"Added {name} as {role}")
@app.callback()
def main(verbose: bool = typer.Option(False, "--verbose", "-v")):
"""Project management tool with verbose mode."""
if verbose:
typer.echo("Verbose mode enabled")
$ python app.py init my-project
Initialized project: my-project
$ python app.py users add alice --role admin
Added alice as admin
$ python app.py users list
alice
bob
charlie
The @app.callback() decorator defines options for the parent command (like Click's group options).
13. Typer: Rich Integration
With typer[all], you get Rich integration for beautiful output:
import typer
from rich.console import Console
from rich.table import Table
from rich.progress import track
import time
console = Console()
app = typer.Typer()
@app.command()
def status():
"""Show system status with a rich table."""
table = Table(title="System Status")
table.add_column("Service", style="cyan")
table.add_column("Status", style="green")
table.add_column("Latency", justify="right")
table.add_row("Database", "[green]healthy[/green]", "12ms")
table.add_row("Cache", "[green]healthy[/green]", "2ms")
table.add_row("Queue", "[yellow]degraded[/yellow]", "450ms")
console.print(table)
@app.command()
def process(count: int = typer.Option(50, help="Items to process.")):
"""Process items with a progress bar."""
for _ in track(range(count), description="Processing..."):
time.sleep(0.02)
console.print("[bold green]Done![/bold green]")
Rich provides tables, progress bars, syntax highlighting, markdown rendering, trees, and panels — all working in the terminal.
14. Typer: Testing
Typer has its own CliRunner that wraps Click's:
from typer.testing import CliRunner
runner = CliRunner()
def test_init():
result = runner.invoke(app, ["init", "my-project"])
assert result.exit_code == 0
assert "Initialized project: my-project" in result.output
def test_add_user():
result = runner.invoke(app, ["users", "add", "alice", "--role", "admin"])
assert result.exit_code == 0
assert "Added alice as admin" in result.output
def test_list_users():
result = runner.invoke(app, ["users", "list"])
assert result.exit_code == 0
assert "alice" in result.output
Same pattern as Click: invoke, check exit code, assert output. All your pytest fixtures and patterns work as expected.
15. Click vs Typer Comparison
| Feature | Click | Typer |
|---|---|---|
| Parameter definition | Decorators | Type hints + defaults |
| Built on | Standalone | Click (wrapper) |
| Shell completion | Plugin needed | Built-in |
| Rich output | click.style/secho | Full Rich integration |
| Custom types | ParamType subclass | Annotated types |
| Minimum Python | 3.7+ | 3.7+ |
| Learning curve | Moderate | Low (if you know type hints) |
| Ecosystem maturity | 10+ years, huge | Newer, growing fast |
| Testing | click.testing.CliRunner | typer.testing.CliRunner |
| Async support | Limited | Limited (same as Click) |
| Boilerplate | Moderate | Minimal |
Rule of thumb: Use Typer for new projects. Use Click when you need deep customization, complex parameter types, or are contributing to an existing Click-based project.
16. Packaging and Distribution
A CLI tool is only useful if people can install it. Use pyproject.toml with entry points:
[project]
name = "mycli"
version = "1.0.0"
description = "My awesome CLI tool"
requires-python = ">=3.9"
dependencies = [
"typer[all]>=0.9",
]
[project.scripts]
mycli = "mycli.main:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
The [project.scripts] section creates an executable entry point. When installed, users can run mycli directly instead of python -m mycli.
For Click, point to the CLI function:
[project.scripts]
mycli = "mycli.main:cli"
Build and publish:
# Build the package
pip install build
python -m build
# Upload to PyPI
pip install twine
twine upload dist/*
# Users install from PyPI
pip install mycli
# Or use pipx for isolated CLI installs
pipx install mycli
pipx is ideal for CLI tools: it installs each tool in its own virtual environment while making the command globally available. Recommend it to your users.
17. Real-World Patterns
Config Files
Load defaults from a config file, with CLI flags overriding:
import json
from pathlib import Path
import typer
CONFIG_PATH = Path.home() / ".mycli.json"
def load_config() -> dict:
if CONFIG_PATH.exists():
return json.loads(CONFIG_PATH.read_text())
return {}
app = typer.Typer()
@app.command()
def run(
host: str = typer.Option(None, help="Server host."),
port: int = typer.Option(None, help="Server port."),
):
"""Run the server with config file + CLI overrides."""
config = load_config()
host = host or config.get("host", "localhost")
port = port or config.get("port", 8000)
typer.echo(f"Starting server on {host}:{port}")
Logging
Wire up Python logging from a --verbose flag:
import logging
import typer
app = typer.Typer()
@app.callback()
def setup(
verbose: int = typer.Option(0, "--verbose", "-v", count=True,
help="Increase verbosity (-v, -vv, -vvv)."),
):
level = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}.get(
verbose, logging.DEBUG
)
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
Progress Bars
Click and Typer both support progress bars for long-running operations:
# Click progress bar
import click
@click.command()
@click.argument("files", nargs=-1, type=click.Path(exists=True))
def process(files):
"""Process multiple files with a progress bar."""
with click.progressbar(files, label="Processing") as bar:
for filepath in bar:
# process each file
import time; time.sleep(0.1)
# Typer with Rich progress
from rich.progress import Progress
@app.command()
def upload(files: list[str] = typer.Argument(..., help="Files to upload.")):
"""Upload files with rich progress."""
with Progress() as progress:
task = progress.add_task("Uploading...", total=len(files))
for f in files:
import time; time.sleep(0.3)
progress.update(task, advance=1)
typer.echo("All files uploaded.")
Error Handling and Exit Codes
import sys
import typer
@app.command()
def validate(config_path: str = typer.Argument(..., help="Path to config.")):
"""Validate a configuration file."""
path = Path(config_path)
if not path.exists():
typer.secho(f"Error: {config_path} not found", fg="red", err=True)
raise typer.Exit(code=1)
try:
data = json.loads(path.read_text())
except json.JSONDecodeError as e:
typer.secho(f"Invalid JSON: {e}", fg="red", err=True)
raise typer.Exit(code=2)
typer.secho("Config is valid!", fg="green")
Use err=True in typer.secho() or click.echo() to write to stderr, keeping stdout clean for piping.
FAQ
Should I use Click or Typer for my Python CLI?
Use Typer if you are starting a new project and prefer a modern, type-hint-driven approach with less boilerplate. Typer is built on top of Click and automatically generates CLI parameters from function signatures. Use Click directly when you need maximum control, have complex custom types, or are working on a project that already uses Click. Both are production-ready and well-maintained.
How do I test a Click or Typer CLI application?
Both Click and Typer provide a CliRunner class for testing. Click uses click.testing.CliRunner and Typer uses typer.testing.CliRunner. You invoke your CLI command programmatically, capture the output and exit code, and assert against them. The runner simulates terminal input and output without spawning a subprocess, making tests fast and reliable.
How do I distribute a Python CLI tool so users can install it with pip?
Define a console_scripts entry point in your pyproject.toml under [project.scripts]. For example: mycli = "mypackage.cli:app". Then build with python -m build and upload to PyPI with twine upload dist/*. Users install with pip install mycli or pipx install mycli. The entry point creates an executable wrapper that calls your function directly.
What is the difference between Click options and arguments?
In Click, arguments are positional parameters (e.g., mycli input.txt) while options are named flags (e.g., mycli --output out.txt). Arguments are required by default and cannot have help text in --help output. Options are optional by default, support --help descriptions, can have defaults, and can be boolean flags. Use arguments for the primary input and options for everything else.
Can I use argparse and Click together?
There is no built-in interop, but you can migrate incrementally. Click and Typer are designed as complete replacements for argparse. Click provides decorators instead of ArgumentParser.add_argument calls, automatic help generation, and better composability. For new commands, use Click or Typer. For existing argparse code, you can wrap it in a Click command or migrate one subcommand at a time.