Python CLI with Typer: Modern Command-Line Apps

Typer is the modern Python library for building command-line interfaces using type annotations — the same approach FastAPI uses for HTTP APIs. Arguments and options are declared as function parameters with type hints; Typer auto-generates help text, validation, shell completion, and error messages. Built by the same author as FastAPI, Typer integrates seamlessly with Rich for beautiful terminal output.

Basic Commands and Arguments

pip install "typer[all]"  # includes Rich for pretty output
# main.py
import typer

app = typer.Typer()

@app.command()
def hello(name: str, count: int = 1):
    """Greet NAME a given number of times."""
    for _ in range(count):
        typer.echo(f"Hello {name}!")

if __name__ == "__main__":
    app()
python main.py --help
# Usage: main.py [OPTIONS] NAME
#   Greet NAME a given number of times.
# Arguments:
#   NAME  [required]
# Options:
#   --count  INTEGER  [default: 1]
#   --help            Show this message and exit.

python main.py Alice
# Hello Alice!

python main.py Alice --count 3
# Hello Alice!
# Hello Alice!
# Hello Alice!

Options and Flags

from typing import Optional
import typer
from enum import Enum

class Format(str, Enum):
    json = "json"
    csv = "csv"
    table = "table"

app = typer.Typer()

@app.command()
def export(
    output: str = typer.Argument(..., help="Output file path"),
    format: Format = typer.Option(Format.json, "--format", "-f", help="Output format"),
    limit: int = typer.Option(100, "--limit", "-n", help="Max rows to export"),
    verbose: bool = typer.Option(False, "--verbose", "-v", is_flag=True),
    include_headers: bool = typer.Option(True, help="Include column headers"),
    api_key: Optional[str] = typer.Option(None, envvar="API_KEY", help="API key"),
):
    """Export data to a file."""
    if verbose:
        typer.echo(f"Exporting {limit} rows as {format} to {output}")

    # Enum value
    if format == Format.json:
        typer.echo("Writing JSON...")
    elif format == Format.csv:
        typer.echo("Writing CSV...")

@app.command()
def version():
    """Show version information."""
    import importlib.metadata
    v = importlib.metadata.version("myapp")
    typer.echo(f"myapp v{v}")

Subcommands (App Groups)

import typer

app = typer.Typer(help="MyApp CLI — manage your application.")

# Sub-apps for grouping related commands
users_app = typer.Typer(help="Manage users.")
db_app = typer.Typer(help="Database operations.")

app.add_typer(users_app, name="users")
app.add_typer(db_app, name="db")

@users_app.command("create")
def create_user(
    username: str,
    email: str,
    admin: bool = typer.Option(False, "--admin", is_flag=True),
):
    """Create a new user."""
    typer.echo(f"Creating user {username} ({email})" + (" [ADMIN]" if admin else ""))

@users_app.command("list")
def list_users(active_only: bool = typer.Option(True)):
    """List all users."""
    typer.echo("Listing users...")

@users_app.command("delete")
def delete_user(username: str):
    """Delete a user."""
    confirm = typer.confirm(f"Delete user '{username}'? This cannot be undone.")
    if confirm:
        typer.echo(f"Deleted {username}")

@db_app.command("migrate")
def migrate(revision: str = "head"):
    """Run database migrations."""
    typer.echo(f"Running migrations to {revision}...")

@db_app.command("reset")
def reset_db(force: bool = typer.Option(False, "--force", "-f")):
    """Reset the database (DESTRUCTIVE)."""
    if not force:
        typer.confirm("This will DELETE all data. Continue?", abort=True)
    typer.echo("Database reset.")

if __name__ == "__main__":
    app()
python main.py --help
python main.py users --help
python main.py users create alice alice@example.com --admin
python main.py db migrate
python main.py db reset --force

Rich Terminal Output

import typer
from rich.console import Console
from rich.table import Table
from rich.progress import track
from rich import print as rprint
import time

console = Console()
app = typer.Typer()

@app.command()
def status():
    """Show system status with a Rich table."""
    table = Table(title="System Status", show_header=True, header_style="bold cyan")
    table.add_column("Service", style="dim")
    table.add_column("Status")
    table.add_column("Latency")

    services = [
        ("API Server",  "[green]● Running[/green]",   "12ms"),
        ("Database",    "[green]● Running[/green]",   "3ms"),
        ("Cache",       "[green]● Running[/green]",   "1ms"),
        ("Queue",       "[yellow]● Degraded[/yellow]", "250ms"),
    ]
    for name, status, latency in services:
        table.add_row(name, status, latency)

    console.print(table)

@app.command()
def process(count: int = 100):
    """Process items with a progress bar."""
    results = []
    for item in track(range(count), description="Processing..."):
        time.sleep(0.02)
        results.append(item * 2)
    console.print(f"[bold green]✓[/bold green] Processed {len(results)} items.")

@app.command()
def error_demo():
    """Demonstrate error styling."""
    console.print("[bold red]Error:[/bold red] Connection refused to database.")
    raise typer.Exit(code=1)

Prompts and Confirmation

@app.command()
def setup():
    """Interactive setup wizard."""
    typer.echo("Welcome to MyApp Setup")
    typer.echo("=" * 30)

    db_host = typer.prompt("Database host", default="localhost")
    db_port = typer.prompt("Database port", default=5432, type=int)
    db_name = typer.prompt("Database name")
    api_key = typer.prompt("API key", hide_input=True, confirmation_prompt=True)

    enable_cache = typer.confirm("Enable Redis cache?", default=True)

    typer.echo("\nConfiguration summary:")
    typer.echo(f"  Database: {db_host}:{db_port}/{db_name}")
    typer.echo(f"  Cache: {'enabled' if enable_cache else 'disabled'}")

    if typer.confirm("\nSave this configuration?"):
        # write config to file
        typer.echo("[green]Configuration saved![/green]")

File and Path Arguments

from pathlib import Path
import typer

@app.command()
def process_file(
    input_file: Path = typer.Argument(
        ...,
        exists=True,
        file_okay=True,
        dir_okay=False,
        readable=True,
        resolve_path=True,
        help="Input CSV file to process",
    ),
    output_dir: Path = typer.Option(
        Path("./output"),
        "--out", "-o",
        file_okay=False,
        dir_okay=True,
        writable=True,
        help="Output directory",
    ),
    pattern: str = typer.Option("*.csv", help="File pattern to match"),
):
    """Process input file and write results to output directory."""
    output_dir.mkdir(parents=True, exist_ok=True)
    typer.echo(f"Processing {input_file} → {output_dir}")
    data = input_file.read_text()
    result = data.upper()  # example transformation
    out_path = output_dir / f"processed_{input_file.name}"
    out_path.write_text(result)
    typer.echo(f"Written to {out_path}")

Testing CLI Apps

# tests/test_cli.py
from typer.testing import CliRunner
from myapp.cli import app

runner = CliRunner()

def test_hello():
    result = runner.invoke(app, ["hello", "Alice"])
    assert result.exit_code == 0
    assert "Hello Alice!" in result.output

def test_hello_count():
    result = runner.invoke(app, ["hello", "Bob", "--count", "3"])
    assert result.exit_code == 0
    assert result.output.count("Hello Bob!") == 3

def test_export_json():
    result = runner.invoke(app, ["export", "out.json", "--format", "json"])
    assert result.exit_code == 0

def test_delete_user_confirmed():
    result = runner.invoke(app, ["users", "delete", "alice"], input="y\n")
    assert result.exit_code == 0
    assert "Deleted alice" in result.output

def test_delete_user_aborted():
    result = runner.invoke(app, ["users", "delete", "alice"], input="n\n")
    assert "Deleted" not in result.output

Packaging and Distribution

# pyproject.toml — register CLI entry point
[project.scripts]
myapp = "myapp.cli:app"

# After pip install, users run:
# myapp --help
# myapp users create alice
# Generate shell completion
myapp --install-completion bash    # adds to ~/.bashrc
myapp --install-completion zsh     # adds to ~/.zshrc
myapp --install-completion powershell  # Windows

# Or print completion script for manual install
myapp --show-completion bash

Frequently Asked Questions

Typer vs Click vs argparse — which to choose?
Typer is built on Click and adds type annotation support, auto-help generation, and Rich integration. Use Typer for new projects — it requires less boilerplate. Use Click directly if you need advanced plugin systems or fine-grained control. Use argparse only if you need no third-party dependencies (e.g., in stdlib scripts).
How do I pass a list as a CLI argument?
Use list[str] as the type: def process(files: list[str] = typer.Argument(...)). Typer auto-handles multiple values. For options: items: Optional[list[str]] = typer.Option(None) and pass --items a --items b --items c.
Can I use Typer in async applications?
Typer is synchronous by default. For async commands, use asyncio.run() inside the command function: def my_command(): asyncio.run(async_main()). There's no native async support in Typer because CLIs are typically I/O-sequential.