Why argparse becomes a burden as your project grows
My automation project started at just 200 lines — mostly a few basic commands using argparse. But a few months later, when the codebase grew to nearly 2,000 lines, CLI management alone took up around 300 lines — endless add_argument, type=int, help=... repeated over and over. Every time I needed to add a new command, I’d have to scroll down and find the right spot, terrified of breaking something that already worked.
argparse isn’t wrong — it just predates Python’s type hints. click is better, but you still have to declare separate decorators for each parameter. Then Typer came along and completely changed the way I write CLIs.
What Typer is and how it differs from argparse/click
Typer is a CLI library built on top of Click, but instead of using decorators to declare parameters, it reads directly from Python function type hints. This means:
- You write normal Python functions with type hints
- Typer automatically creates the CLI interface, validates data, and generates help text
- Auto-completion works out-of-the-box with a single command
- Less code, more readable, and type-safe from the start
In short: type hints serve as both code documentation and a “schema” that Typer uses to build the CLI — no need to declare things twice in two different places.
Installation and your first command
Install Typer with full features including rich output and auto-completion:
pip install "typer[all]"
Create a main.py file with your first command:
import typer
app = typer.Typer()
@app.command()
def greet(name: str, count: int = 1):
"""Greet the user."""
for _ in range(count):
typer.echo(f"Hello, {name}!")
if __name__ == "__main__":
app()
Try it right away:
python main.py --help
python main.py World
python main.py "John Doe" --count 3
The --help output is automatically generated from type hints — name is a required argument because it has no default value, and count is optional with a default of 1. No extra code needed.
Automatic Validation with Type Hints
This is the biggest strength. When you declare count: int, Typer automatically validates it and returns a clear error if the user enters an invalid value:
python main.py World --count abc
# Error: Invalid value for '--count': 'abc' is not a valid integer.
You can also use more complex data types like Path:
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer()
@app.command()
def process_file(
input_file: Path = typer.Argument(..., help="Input file to process"),
output_dir: Path = typer.Option(Path("."), help="Directory to save results"),
verbose: bool = False,
max_lines: Optional[int] = None,
):
if not input_file.exists():
typer.echo(f"Error: File {input_file} does not exist", err=True)
raise typer.Exit(1)
typer.echo(f"Processing: {input_file} → {output_dir}")
if verbose:
typer.echo(f"Limit: {max_lines or 'unlimited'} lines")
Path type automatically validates that the path is valid. Optional[int] accepts an int or None. The ... in typer.Argument(...) means required — no default value.
Enum — Restricting to a Set of Valid Values
Instead of manually validating with if format not in ["json", "csv"], use an Enum and let Typer handle it:
from enum import Enum
import typer
app = typer.Typer()
class OutputFormat(str, Enum):
json = "json"
csv = "csv"
text = "text"
@app.command()
def export(
data: str,
format: OutputFormat = OutputFormat.json,
):
typer.echo(f"Exporting data in format: {format.value}")
python main.py export "data" --format csv
python main.py export "data" --format xml
# Error: Invalid value for '--format': 'xml' is not one of 'json', 'csv', 'text'.
Bonus: auto-completion will also suggest exactly these 3 values when the user presses Tab in the terminal.
Subcommands — Organizing Commands into Groups
As your project grows, a single command isn’t enough. Typer supports nested subcommands cleanly using add_typer():
import typer
from pathlib import Path
app = typer.Typer()
db_app = typer.Typer()
user_app = typer.Typer()
app.add_typer(db_app, name="db", help="Manage database")
app.add_typer(user_app, name="user", help="Manage users")
@db_app.command("migrate")
def db_migrate(version: str = "latest"):
typer.echo(f"Running migration to version: {version}")
@db_app.command("backup")
def db_backup(output: Path = Path("backup.sql")):
typer.echo(f"Backup database → {output}")
@user_app.command("create")
def user_create(username: str, email: str, admin: bool = False):
role = "admin" if admin else "user"
typer.echo(f"Creating {role}: {username} ({email})")
if __name__ == "__main__":
app()
The command structure becomes clear and easy to extend:
python main.py db migrate --version 1.5
python main.py db backup --output /tmp/backup.sql
python main.py user create john [email protected] --admin
python main.py --help # show all command groups
Enabling Auto-completion with a Single Command
This is my favorite feature — something argparse doesn’t have, and click requires complex configuration for:
# Bash
python main.py --install-completion bash
# Zsh
python main.py --install-completion zsh
# Fish
python main.py --install-completion fish
# Reload shell to activate
source ~/.bashrc
Then press Tab while typing a command — Typer suggests subcommands, options, and Enum values automatically. For tools with many commands, this feature saves users a significant amount of time.
Rich Output — Tables and Colors in the Terminal
Typer bundles rich, so you can print tables, progress bars, and colors without any extra installation:
from rich.console import Console
from rich.table import Table
import typer
console = Console()
app = typer.Typer()
@app.command()
def list_tasks():
"""Display the list of tasks."""
table = Table(title="Task List")
table.add_column("ID", style="cyan", width=6)
table.add_column("Name", style="white")
table.add_column("Status", style="green")
table.add_row("1", "Backup database", "✓ Done")
table.add_row("2", "Deploy staging", "⟳ Running")
table.add_row("3", "Send report", "✗ Error")
console.print(table)
Organizing Code as Your Project Grows
A hard lesson from my 2,000-line project: don’t cram all your commands into one file. The pattern I use is to split each command group into its own module:
mycli/
├── __init__.py # main app + add_typer()
├── commands/
│ ├── __init__.py
│ ├── db.py # db_app = typer.Typer()
│ ├── user.py # user_app = typer.Typer()
│ └── report.py # report_app = typer.Typer()
main.py # entry point
The mycli/__init__.py file:
import typer
from mycli.commands.db import db_app
from mycli.commands.user import user_app
from mycli.commands.report import report_app
app = typer.Typer(name="mycli", help="Project management tool")
app.add_typer(db_app, name="db")
app.add_typer(user_app, name="user")
app.add_typer(report_app, name="report")
Each file exports an independent typer.Typer() instance. Adding a new command group only requires creating a new file and adding 2 lines to __init__.py — no touching existing code.
Compared to using argparse, my CLI section shrank from ~300 to ~150 lines after switching to Typer, while also eliminating dozens of small validation bugs that I’d previously forgotten to handle.
Conclusion
Typer isn’t magic — it simply leverages what you’re already used to writing (functions + type hints) to generate a complete CLI with validation, help text, auto-completion, and rich output. If you’re using argparse and finding your CLI code getting messy, now is a good time to give it a try.
A practical approach: convert one small command first, get it running and get comfortable with the pattern, then gradually migrate the rest. Don’t try to refactor everything all at once.

