Context & Why: The 2 AM Script Problem
It was 2:07 AM when the on-call alert fired. A batch job had silently failed because someone ran the maintenance script with --mode=delet instead of --mode=delete. No validation, no error. Just… nothing happened. Three hours of debugging later, we found the root cause: a hand-rolled argparse script that accepted any string without checking it.
That night convinced me to stop writing ad-hoc CLI scripts and start building proper tools. Since then, I’ve rebuilt four internal tools with Typer across two different teams. Bad-argument failures dropped to zero. The --help output stopped lying.
Typer is a Python library built on top of Click. It uses Python type hints to define CLI arguments, options, and validation automatically. Write a normal Python function with type annotations, and Typer generates the interface — including --help text, completion scripts, and input validation.
Here’s what makes it different from argparse or plain Click:
- Type hints are the argument definitions — no duplication
- Validation happens before your function runs — bad input never reaches your logic
- Autocomplete works out of the box for bash, zsh, and fish
- Subcommands are just Python functions grouped under an
app
Installation
Start with a clean virtual environment — mixing Typer versions across projects causes subtle issues with Click compatibility:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "typer[all]"
The [all] extra pulls in rich for colored output and shellingham for shell detection (needed for autocomplete installation). If you’re building something minimal for a Docker container, pip install typer works too.
Verify the install:
python -c "import typer; print(typer.__version__)"
Configuration: Building Your First Real CLI
Forget the toy “hello world” examples. Here’s the kind of thing that actually runs in production — a database maintenance tool with subcommands, enums for validated options, and proper error handling.
Basic App Structure
import typer
from enum import Enum
from pathlib import Path
from typing import Optional
app = typer.Typer(name="dbmaint", help="Database maintenance toolkit")
class RunMode(str, Enum):
dry_run = "dry-run"
execute = "execute"
rollback = "rollback"
@app.command()
def cleanup(
database: str = typer.Argument(..., help="Database name to target"),
mode: RunMode = typer.Option(RunMode.dry_run, help="Execution mode"),
older_than_days: int = typer.Option(30, min=1, max=365, help="Delete records older than N days"),
config: Optional[Path] = typer.Option(None, exists=True, help="Path to config file"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
):
"""Clean up old records from the target database."""
if verbose:
typer.echo(f"Mode: {mode.value}, DB: {database}, Days: {older_than_days}")
if mode == RunMode.dry_run:
typer.secho("[DRY RUN] No changes will be made", fg=typer.colors.YELLOW)
elif mode == RunMode.execute:
typer.secho(f"Cleaning records older than {older_than_days} days...", fg=typer.colors.GREEN)
# actual logic here
if __name__ == "__main__":
app()
All three validations are automatic. RunMode is an Enum, so passing --mode=delet fails immediately with a clear error listing valid choices. The min=1, max=365 constraint on older_than_days rejects out-of-range integers before your function runs. The exists=True on the config path checks that the file actually exists on disk.
Running it without arguments shows clean help output:
python dbmaint.py cleanup --help
Usage: dbmaint cleanup [OPTIONS] DATABASE
Clean up old records from the target database.
Arguments:
DATABASE Database name to target [required]
Options:
--mode [dry-run|execute|rollback] Execution mode [default: dry-run]
--older-than-days INTEGER RANGE Delete records older than N days [default: 30]
--config PATH Path to config file
-v, --verbose / --no-verbose
--help Show this message and exit.
Adding Subcommands
Real tools have multiple operations. With Typer, each subcommand is a separate function decorated with @app.command():
@app.command()
def backup(
database: str = typer.Argument(...),
output: Path = typer.Option(Path("./backups"), writable=True, help="Backup destination"),
compress: bool = typer.Option(True, help="Compress output with gzip"),
):
"""Create a database backup."""
typer.echo(f"Backing up {database} to {output}")
# backup logic here
@app.command()
def restore(
database: str = typer.Argument(...),
backup_file: Path = typer.Argument(..., exists=True, help="Backup file to restore from"),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
):
"""Restore a database from backup."""
if not force:
confirm = typer.confirm(f"This will overwrite {database}. Continue?")
if not confirm:
raise typer.Abort()
typer.echo(f"Restoring {database} from {backup_file}")
Run python dbmaint.py --help and all three commands appear automatically. Nobody maintains a dispatch table. Nobody updates help text by hand — which saves a surprising amount of time in code review.
Nested Apps for Complex Tools
For tools with many commands, group them into sub-apps:
import typer
app = typer.Typer()
db_app = typer.Typer(help="Database operations")
user_app = typer.Typer(help="User management")
app.add_typer(db_app, name="db")
app.add_typer(user_app, name="user")
@db_app.command("migrate")
def db_migrate(target: str = typer.Argument("head")):
"""Run database migrations."""
typer.echo(f"Migrating to: {target}")
@user_app.command("create")
def user_create(username: str, email: str, admin: bool = False):
"""Create a new user account."""
typer.echo(f"Creating user: {username} ({email})")
Usage becomes python tool.py db migrate head and python tool.py user create johndoe [email protected]. The hierarchy shows up in --help at every level.
Custom Validation with Callbacks
Sometimes the built-in constraints aren’t enough. Typer supports parameter callbacks for custom validation:
import re
def validate_db_name(value: str) -> str:
if not re.match(r'^[a-z][a-z0-9_]{2,63}$', value):
raise typer.BadParameter(
"Database name must start with a letter, contain only lowercase letters, "
"digits, or underscores, and be 3-64 characters long."
)
return value
@app.command()
def create_db(
name: str = typer.Argument(..., callback=validate_db_name, help="New database name"),
):
"""Create a new database."""
typer.secho(f"Creating database: {name}", fg=typer.colors.GREEN)
The callback runs before your function body. Pass a bad name and you get a clear error with the parameter name, the value, and your message — before any database connection is attempted.
Verification & Monitoring
Testing CLI Commands
Typer ships with a test client that captures output without spawning a subprocess — critical for fast unit tests:
from typer.testing import CliRunner
from myapp import app
runner = CliRunner()
def test_cleanup_dry_run():
result = runner.invoke(app, ["cleanup", "mydb", "--mode", "dry-run"])
assert result.exit_code == 0
assert "DRY RUN" in result.output
def test_cleanup_invalid_mode():
result = runner.invoke(app, ["cleanup", "mydb", "--mode", "invalid"])
assert result.exit_code != 0
assert "Invalid value" in result.output
def test_cleanup_days_out_of_range():
result = runner.invoke(app, ["cleanup", "mydb", "--older-than-days", "999"])
assert result.exit_code != 0
These tests run in ~5ms each. Watch the exit_code — Typer exits with code 2 for bad arguments, code 1 for Abort(), and code 0 for success. In a CI pipeline, those codes are what your test suite catches before anything ships.
Exit Codes and Shell Integration
When wrapping Typer tools in shell scripts or cron jobs, exit codes matter:
# In your cron job or CI script:
python dbmaint.py cleanup production --mode execute
if [ $? -ne 0 ]; then
echo "Cleanup failed" | mail -s "DB Alert" [email protected]
fi
Typer propagates exit codes correctly from raised exceptions. Use raise typer.Exit(code=1) to signal failure from within your logic. Shell wrappers pick it up without any extra configuration.
Shell Autocomplete
Once a tool is deployed, install autocompletion so the team actually uses it correctly:
# Install completion for the current shell:
python dbmaint.py --install-completion
# Or generate the script to inspect it:
python dbmaint.py --show-completion
After sourcing your shell config, tab-completing python dbmaint.py cleanup --mode <TAB> shows dry-run execute rollback. That’s the kind of discoverability that keeps people from running the wrong mode at 2 AM.
Packaging as a Proper CLI Tool
Wire Typer into a pyproject.toml entry point to make it installable system-wide:
[project]
name = "dbmaint"
version = "1.0.0"
dependencies = ["typer[all]>=0.12"]
[project.scripts]
dbmaint = "dbmaint.cli:app"
pip install -e .
# Now usable as:
dbmaint cleanup production --mode dry-run
This makes the tool available as a system command, with the same autocomplete and validation as running the script directly.
Moving from raw argparse to Typer changed how our team operates. Bad arguments fail loudly at the CLI layer — not silently inside business logic. The --help text stays in sync with the code automatically. New team members discover options through autocomplete instead of reading source. That 2 AM incident was the last time a typo in a CLI argument caused a production problem.

