cheat sheet
typer
Package-level reference for the Typer CLI library on PyPI — install variants, version policy, extras, and alternatives.
typer
What it is
typer is a Python package for building CLI apps from regular type-annotated functions. Authored by Sebastián Ramírez (also the author of FastAPI), it sits on top of click and turns Python type hints into argument parsers, help text, and shell completion.
Reach for typer when your team already writes typed Python and you want the CLI definition to mirror that style. Drop down to click directly when you need parser behaviour Typer doesn't expose, or when you don't want the additional dependency footprint.
Install
pip install typer
Output: (none — exits 0 on success). Installs typer, click, rich, and shellingham by default on recent releases.
uv add typer
Output: dependency resolved + added to pyproject.toml
poetry add typer
Output: updated lockfile + virtualenv install
pip install "typer[all]" # legacy alias
Output: modern Typer already pulls in rich and shellingham, so [all] is now a no-op alias kept for backward compatibility.
Versioning & Python support
- Current stable line is the
0.xseries. Despite the0.prefix, Typer is widely used in production and follows the same "additive-minor, breaking-rare" convention as FastAPI. - Supports Python 3.7+ on recent releases; older Typer versions support 3.6.
- Pre-
0.9, the package was split intotyper(lean) andtyper[all](withrich+shellingham). From0.9onward, those extras became default dependencies, and theslimextra exists for the lean install. - Pin to
typer>=0.12,<1to avoid accidental upgrades when1.0lands.
Package metadata
- Maintainer: Sebastián Ramírez (
tiangoloon GitHub) - Project home: github.com/fastapi/typer
- Docs: typer.tiangolo.com
- PyPI: pypi.org/project/typer
- License: MIT
- Governance: maintained under the FastAPI org on GitHub
- First released: 2019
- Downloads: tens of millions per month — adoption rose alongside FastAPI.
Optional dependencies & extras
typer(default) — includesclick,rich, andshellingham. Sufficient for almost every use case.typer[slim]— strips outrichandshellingham. Pick this when the CLI ships inside a container or lambda where startup time and image size matter, and you don't need pretty tracebacks or auto-detected shell completion.typer[all]— legacy alias kept for backward compatibility; equivalent to the default install today.
Core dependencies pulled in by the default install:
click— the underlying parser (Typer generates click commands at runtime)rich— colorized help, pretty exception output, tablesshellingham— auto-detect the user's shell fortyper --install-completion
Alternatives
| Package | Trade-off |
|---|---|
click | The underlying library. Use directly when you want explicit decorators instead of inferring from type hints, or when you don't want rich as a transitive dep. |
argparse (stdlib) | Zero-dependency, very small scripts. No type-hint magic; lots of boilerplate. |
fire | Reflective CLI generation from any Python object. Less polished UX; good for ad-hoc exposure. |
cyclopts | Newer type-hint-first CLI framework with Pydantic-style validation. Use when you want richer typed validation than Typer's. |
clap (clap-python) | Bindings to the Rust clap parser. Use only for extreme performance needs. |
Common gotchas
typervstyper-cli.typer-cliwas a separate package providing atypercommand for running scripts without an entry point. It is deprecated; modern Typer ships the runner inside the main package.Annotated[type, typer.Option(...)]is the modern syntax. Older docs showname: str = typer.Option("default", "--name")— that still works, but Typer 0.9+ prefersname: Annotated[str, typer.Option("--name")] = "default"to separate type information from CLI metadata. Mypy and IDE refactors handle the Annotated form better.- Rich-vs-classic output toggled by env var. Set
_TYPER_FORCE_DISABLE_TERMINAL=1to suppress rich rendering (helpful in CI logs and unit tests). Set_TYPER_COMPLETE_TESTING=1when testing completion scripts. typer.Exit()notsys.exit(). Inside commands, raisetyper.Exit(code=1)so Typer'sCliRunnerand exception hooks see the exit cleanly. Callingsys.exit()skips Rich's exception formatting.- Sub-commands need
app.add_typer(sub_app, name="..."). Typer's group model is implicit — there is no@app.group()decorator. Build a sub-Typer()instance and attach it. - Optional
Annotatedarguments require careful default placement. With the Annotated form, the Python default value goes after the type annotation (= "default"), not insidetyper.Option(...). Putting the default inside the option metadata still works but conflicts with Python's positional-argument rules in some cases. - Auto-completion installation writes to user's shell rc.
--install-completionappends to~/.bashrc,~/.zshrc, or the fish completion dir without prompting. Document this for users who don't want their shell config modified.
Real-world recipes
Patterns that show up repeatedly in production Typer apps. They lean on Annotated[type, typer.Option(...)] because that is the modern recommendation — older positional-default style still works but produces uglier signatures.
Subcommands assembled from sub-Typer() instances
Typer's group model is implicit: every nested CLI is its own Typer() instance attached to the parent via app.add_typer(sub, name="…"). This pattern composes well — sub-apps can live in different modules and be wired in main().
# users.py
import typer
from typing_extensions import Annotated
app = typer.Typer(help="User-management commands.")
@app.command("add")
def add(name: str,
admin: Annotated[bool, typer.Option("--admin/--no-admin")] = False):
typer.echo(f"created {name} (admin={admin})")
@app.command("rm")
def rm(name: str,
force: Annotated[bool, typer.Option("-f", "--force")] = False):
typer.echo(f"deleted {name} (force={force})")
# cli.py
import typer
from . import users, hosts
app = typer.Typer(help="alicectl — control plane CLI.")
app.add_typer(users.app, name="users")
app.add_typer(hosts.app, name="hosts")
@app.callback()
def main(config: Annotated[str, typer.Option(envvar="ALICE_CONFIG")] = "config.toml"):
"""Shared global flags land in the callback."""
typer.echo(f"using config {config}")
if __name__ == "__main__":
app()
Output: alicectl users add alice --admin works; alicectl --help lists users and hosts as subcommand groups.
Custom validation via Annotated
For any check more complex than a built-in type, attach a callback. The callback runs after type coercion and either returns the value or raises typer.BadParameter. The pattern keeps validation logic next to the type, not buried inside the command body.
import typer
from typing_extensions import Annotated
def _validate_port(value: int) -> int:
if not 1 <= value <= 65535:
raise typer.BadParameter(f"port {value} out of range 1-65535")
return value
def _validate_email(value: str) -> str:
if "@" not in value:
raise typer.BadParameter(f"{value!r} is not an email")
return value.lower()
app = typer.Typer()
@app.command()
def register(
email: Annotated[str, typer.Option(callback=_validate_email)],
port: Annotated[int, typer.Option(callback=_validate_port)] = 8080,
):
typer.echo(f"register {email} -> :{port}")
Output: register --email alice@example.com --port 9000 succeeds; register --port 70000 errors with Invalid value for '--port': port 70000 out of range 1-65535.
Async commands via a sync wrapper
Typer doesn't run async def functions directly — under the hood it expects a sync callable. The idiomatic wrapper is a tiny asyncio.run(...) adapter; pass kwargs through verbatim so Typer's inference still sees the real parameter types.
import asyncio
import typer
from typing_extensions import Annotated
app = typer.Typer()
async def _fetch(host: str, timeout: float) -> None:
import httpx
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"https://{host}")
typer.echo(f"{host} -> {resp.status_code}")
@app.command()
def fetch(
host: str,
timeout: Annotated[float, typer.Option("-t", "--timeout")] = 5.0,
):
"""Fetch HOST asynchronously."""
asyncio.run(_fetch(host, timeout))
Output: myapp fetch example.com -t 2.5 runs the async function; Typer sees a normal sync function for help and type inference.
Rich-formatted tabular output
Typer ships Rich by default (via the rich dep). Inside a command, instantiate rich.console.Console and print whatever you want — tables, panels, syntax-highlighted code. The integration is automatic; nothing in Typer() configuration is needed.
import typer
from rich.console import Console
from rich.table import Table
app = typer.Typer()
console = Console()
@app.command()
def status():
table = Table(title="Workers")
table.add_column("Name", style="cyan")
table.add_column("State", style="green")
table.add_column("Last seen", style="magenta")
for name, state, seen in [("worker-a", "ready", "2s"),
("worker-b", "draining", "12s")]:
table.add_row(name, state, seen)
console.print(table)
Output: a coloured Rich table; when stdout is redirected to a file, Rich auto-detects and falls back to plain text — no if isatty() plumbing needed.
CLI distribution patterns
A Typer CLI in a script directory is a personal helper; a CLI on PATH distributable via pipx is a real tool. The wiring is identical to a click CLI (Typer generates a click cli object at runtime), with one bonus: Typer's --install-completion wires up shell completion automatically.
[project.scripts] entry point
# pyproject.toml
[project]
name = "alicectl"
version = "0.4.0"
requires-python = ">=3.10"
dependencies = ["typer>=0.12,<1"]
[project.scripts]
alicectl = "alicectl.cli:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Output: pip install . or pipx install . puts alicectl on PATH. The entry-point target must be a Typer() instance or a function calling app() — Typer's __call__ handles either.
--install-completion (Typer's built-in)
Typer ships a top-level --install-completion flag that auto-detects the user's shell via the shellingham dep and writes the appropriate completion file. The user runs it once after install.
alicectl --install-completion
Output: zsh completion installed in /Users/alice/.zfunc/_alicectl. Completion will take effect once you restart the terminal.
For containers or CI where shellingham can't detect the shell, pass it explicitly: alicectl --install-completion bash. The --show-completion flag prints the script to stdout without writing — useful for vendoring into a dotfiles repo.
typer[slim] for minimal images
Production containers that don't need Rich tracebacks and shell auto-detect should install the lean variant:
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir 'typer[slim]' .
ENTRYPOINT ["alicectl"]
Output: drops ~3 MB of dependencies (rich + shellingham); CLI behaves identically but help is monochrome and --install-completion requires the explicit shell argument.
Single-binary distribution with shiv or PEX
For non-Python users, bundle into a zipapp. Both work identically with Typer — the CLI is just a Python callable.
pip install shiv
shiv -e alicectl.cli:app -o alicectl --reproducible .
./alicectl --help
Output: a single executable that runs on any host with Python 3. --reproducible strips timestamps so the binary hashes identically between builds.
Version migration guide
Typer's 0.x line is the only line so far, but several minor releases broke API surface in ways that bite re-runs of old notebooks. The shifts below are worth knowing before pinning.
0.x modernization — Annotated syntax
The biggest stylistic break is the move from positional defaults to Annotated[type, typer.Option(...)]. Both still work; Annotated is the recommended modern form because it survives mypy and IDE refactors better.
# Old style — default-value-as-Option (still works)
import typer
app = typer.Typer()
@app.command()
def greet(name: str = typer.Option(..., "--name", "-n", help="Who to greet")):
typer.echo(f"hello {name}")
# New style — Annotated wraps the option metadata
import typer
from typing_extensions import Annotated
app = typer.Typer()
@app.command()
def greet(name: Annotated[str, typer.Option("--name", "-n", help="Who to greet")]):
typer.echo(f"hello {name}")
Output: identical CLI; the new style lets mypy distinguish argument types cleanly and lets refactor tools follow the signature.
typer-cli package deprecation
The legacy typer-cli package provided a typer runner command for ad-hoc scripts. It's been deprecated for years — modern Typer ships everything in the main package. Remove typer-cli from any requirements and replace the invocation:
# Old — depended on typer-cli
typer run myscript.py
# New — Typer is a normal Python entry point
python myscript.py # if the script calls app() in __main__
Output: the script runs directly; typer-cli can be uninstalled.
typer[all] → default
Up to 0.8, typer[all] was needed for Rich tracebacks and shell auto-detection; from 0.9 onward, those dependencies became default and typer[all] is a no-op alias kept for backward compatibility. Update old requirements.txt:
# Before
typer[all]==0.8.0
# After
typer>=0.12,<1
For lean installs (lambda, slim containers), switch to typer[slim].
typer.Exit over sys.exit
Older code used sys.exit(1) to fail. Typer's exception hooks expect typer.Exit(code=1) — using sys.exit skips Rich's pretty traceback. Audit any command that aborts mid-execution:
# Old
import sys
def cmd(...):
if bad: sys.exit(1)
# New
import typer
def cmd(...):
if bad: raise typer.Exit(code=1)
Testing strategies
Typer re-exports click.testing.CliRunner, so the testing surface is the same shape as click. The difference: Typer's CliRunner works on both Typer() apps and individual commands, and it supports the Annotated-style signatures without quirks.
from typer.testing import CliRunner
from alicectl.cli import app
runner = CliRunner()
def test_help_lists_subcommands():
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "users" in result.output
assert "hosts" in result.output
def test_users_add():
result = runner.invoke(app, ["users", "add", "alice", "--admin"])
assert result.exit_code == 0
assert "created alice (admin=True)" in result.output
def test_validation_failure():
result = runner.invoke(app, ["register", "--email", "alice"])
assert result.exit_code == 2
assert "not an email" in result.output
def test_env_var_fallback():
result = runner.invoke(app, ["--help"], env={"ALICE_CONFIG": "/etc/alice.toml"})
assert result.exit_code == 0
Output: all four tests run in-process — no subprocess, no python -m alicectl overhead.
Key patterns specific to Typer:
_TYPER_FORCE_DISABLE_TERMINAL=1in the test env disables Rich rendering. Output is then plain ASCII, easier to assert on.- Async commands — wrap with
asyncio.runinside the command body (see Recipes); the runner sees a sync function and tests it normally. mix_stderr=False— passed toCliRunner(mix_stderr=False)for tests that need stderr separately. Useful for asserting that warnings land on stderr and machine output on stdout.input="…"feeds stdin intotyper.prompt()flows:runner.invoke(app, ["init"], input="alicedev\n8080\n").
Troubleshooting common errors
| Symptom | Cause | Fix |
|---|---|---|
Missing argument 'NAME' for a parameter that has a default | Annotated form with default placed inside typer.Option(...) instead of as the parameter default. | Move the default to the Python position: name: Annotated[str, typer.Option("--name")] = "default". |
TypeError: __init__() got an unexpected keyword argument 'show_default' | Passing a Typer-specific kwarg to a positional typer.Argument(...) when it only applies to typer.Option(...). | Switch to typer.Option if you need the flag, or drop the kwarg. |
| Help text empty for an option | Either missing docstring on the command or option declared without help="...". Typer infers from the docstring only for the command, not per-option. | Add help="…" inside typer.Option(...). |
--install-completion reports Could not detect shell | shellingham failed (no parent shell metadata, or typer[slim] install). | Pass the shell explicitly: myapp --install-completion zsh. |
Subcommand decorators look right but --help shows no subcommands | add_typer(sub, name="…") not called, or sub-app created with typer.Typer() after the parent's app() ran. | Wire the sub-Typer at module level, before if __name__ == "__main__": app(). |
| Rich traceback obscures the real exception | Default Typer traceback handler trims frames aggressively. | Run with _TYPER_FORCE_DISABLE_TERMINAL=1 or set app = Typer(pretty_exceptions_enable=False) while debugging. |
typer.Exit doesn't show error text | typer.Exit(code=1) is a clean exit, not an error. To show a message and exit, typer.echo("error", err=True); raise typer.Exit(1). | Pair echo(..., err=True) with raise typer.Exit(code=1). |
| Async command silently returns None | async def used directly as a Typer command — Typer doesn't await. | Wrap with a sync function that calls asyncio.run(...). |
See also
- Python: typer — API tutorial, examples, testing
- Packages: pip-click — the underlying library Typer wraps
- Concept: pipes — how CLI tools compose