Skip to content

Python CLI

FrameworkBest ForStyleDependencies
TyperNew projects, type-hint codebasesType hintsClick + Typer
ClickComplex CLIs, decorator preferenceDecoratorsClick
argparseZero-dependency scriptsImperativestdlib
django-typerDjango commands needing rich outputType hintsTyper + Django
django-clickDjango commands, minimal boilerplateDecoratorsClick + Django

The recommended default for new CLI projects. Uses Python type hints for argument definitions, builds on Click underneath.

import typer
app = typer.Typer()
@app.command()
def greet(name: str, count: int = 1):
"""Greet someone COUNT times."""
for _ in range(count):
typer.echo(f"Hello, {name}!")
if __name__ == "__main__":
app()
Terminal window
python greet.py Alice --count 3
import typer
from pathlib import Path
from typing import Optional
app = typer.Typer()
@app.command()
def process(
# Positional argument (required)
path: Path,
# Option with short flag
output: Path = typer.Option("out.json", "--output", "-o"),
# Boolean flag
verbose: bool = typer.Option(False, "--verbose", "-v"),
# Optional with default None
tag: Optional[str] = typer.Option(None, help="Tag the run"),
# Multiple values
exclude: list[str] = typer.Option([], "--exclude", "-e"),
):
if verbose:
typer.echo(f"Processing {path}")
import typer
app = typer.Typer()
db_app = typer.Typer(help="Database operations")
app.add_typer(db_app, name="db")
@db_app.command()
def migrate():
"""Run pending migrations."""
typer.echo("Migrating...")
@db_app.command()
def seed(count: int = 100):
"""Seed sample data."""
typer.echo(f"Seeding {count} records")
@app.command()
def version():
"""Print version."""
typer.echo("1.0.0")
Terminal window
mycli db migrate
mycli db seed --count 50
mycli version

Install with pip install typer[all] or pip install rich separately.

from rich.console import Console
from rich.table import Table
console = Console()
def show_results(results: list[dict]):
table = Table(title="Results")
table.add_column("Name")
table.add_column("Status", style="green")
for r in results:
table.add_row(r["name"], r["status"])
console.print(table)
import typer
from rich.progress import track
@app.command()
def process(items: int = 100):
for _ in track(range(items), description="Processing..."):
do_work()

Use when you prefer decorators over type hints, or when you need Click’s advanced features directly (custom parameter types, shell completion plugins, lazy-loaded groups).

import click
@click.command()
@click.argument("name")
@click.option("--count", "-c", default=1, help="Number of times.")
def greet(name, count):
"""Greet someone COUNT times."""
for _ in range(count):
click.echo(f"Hello, {name}!")
if __name__ == "__main__":
greet()
import click
@click.group()
def cli():
"""My CLI tool."""
@cli.command()
@click.argument("path", type=click.Path(exists=True))
@click.option("--format", type=click.Choice(["json", "csv"]))
def convert(path, format):
"""Convert a file."""
click.echo(f"Converting {path} to {format}")
@cli.command()
def status():
"""Show status."""
click.secho("OK", fg="green")
@click.command()
@click.argument("input", type=click.File("r"))
@click.argument("output", type=click.File("w"))
def transform(input, output):
"""Read INPUT, write to OUTPUT. Use '-' for stdin/stdout."""
data = input.read()
output.write(data.upper())

Use for zero-dependency scripts, stdlib-only environments, or when extending existing argparse-based tools.

import argparse
parser = argparse.ArgumentParser(description="Process files")
parser.add_argument("path", help="Input file path")
parser.add_argument("-o", "--output", default="out.json")
parser.add_argument("-v", "--verbose", action="store_true")
parser.add_argument("--format", choices=["json", "csv"], default="json")
# Subcommands
subparsers = parser.add_subparsers(dest="command")
sub = subparsers.add_parser("convert")
sub.add_argument("file")
args = parser.parse_args()

If your project uses Django, lean into management commands rather than building a standalone CLI. Management commands get the ORM, settings, and app registry for free. Use django-typer or django-click to remove the boilerplate.

SituationApproach
Needs ORM, models, or settingsManagement command
Cron job or scheduled taskManagement command
Data import/exportManagement command
No Django dependencies at allStandalone CLI (Typer/Click)
Tool may be extracted to own packageStandalone CLI
myapp/management/commands/import_data.py
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Import data from a JSON file"
def add_arguments(self, parser):
parser.add_argument("file", type=str)
parser.add_argument("--clear", action="store_true")
def handle(self, *args, **options):
file = options["file"]
clear = options["clear"]
self.stdout.write(f"Importing from {file}")

pip install django-typer[rich]

myapp/management/commands/import_data.py
from django_typer.management import TyperCommand
import typer
class Command(TyperCommand):
help = "Import data from a JSON file"
def handle(
self,
file: str = typer.Argument(..., help="Path to JSON file"),
clear: bool = typer.Option(False, help="Clear existing data first"),
):
if clear:
MyModel.objects.all().delete()
self.stdout.write(f"Importing from {file}")

pip install django-click

myapp/management/commands/import_data.py
import djclick as click
@click.command()
@click.argument("file")
@click.option("--clear", is_flag=True, help="Clear existing data first")
def command(file, clear):
"""Import data from a JSON file."""
if clear:
MyModel.objects.all().delete()
click.echo(f"Importing from {file}")
[project.scripts]
mycli = "mypackage.cli:app"

After pip install . or pip install -e ., the command mycli is available system-wide.

mypackage/
__init__.py
cli.py # Typer app, entry point
commands/ # Subcommand modules (optional)
db.py
export.py
core.py # Business logic (keep CLI-free)

Separate CLI wiring from business logic. cli.py handles arguments, output formatting, and exit codes. core.py handles the actual work and is importable without Typer/Click.

from importlib.metadata import version
app = typer.Typer()
def version_callback(value: bool):
if value:
typer.echo(version("mypackage"))
raise typer.Exit()
@app.callback()
def main(
version: bool = typer.Option(False, "--version", callback=version_callback,
is_eager=True),
):
"""My CLI tool."""
import sys
@app.command()
def check(path: Path):
errors = validate(path)
if errors:
for e in errors:
typer.echo(e, err=True)
raise typer.Exit(code=1)
@app.command()
def delete(name: str, force: bool = typer.Option(False, "--force")):
if not force:
typer.confirm(f"Delete {name}?", abort=True)
do_delete(name)
from pathlib import Path
import tomllib
CONFIG_DIR = Path.home() / ".config" / "mycli"
CONFIG_FILE = CONFIG_DIR / "config.toml"
def load_config() -> dict:
if CONFIG_FILE.exists():
return tomllib.loads(CONFIG_FILE.read_text())
return {}

Follow XDG conventions: config in ~/.config/appname/, data in ~/.local/share/appname/, cache in ~/.cache/appname/.

@app.command()
def export(path: Path, verbose: bool = False):
if verbose:
typer.echo("Loading data...", err=True)
data = load()
# stdout carries data; stderr carries diagnostics
typer.echo(json.dumps(data))

This keeps stdout pipeable: mycli export data.json | jq '.items'.

PatternProblemFix
Business logic in CLI functionsCan’t test without invoking CLIExtract to importable module
sys.exit() deep in libraryKills the process; caller can’t handle errorsRaise exceptions, let CLI layer call sys.exit
Print to stdout for diagnosticsBreaks piping to jq, grep, other toolsUse stderr for status messages
Global state for configHard to test, surprising side effectsPass config explicitly or use dependency injection
Catching all exceptionsHides bugs, produces misleading error messagesCatch specific exceptions at the CLI boundary