cheat sheet

typer

Package-level reference for the Typer CLI library on PyPI — install variants, version policy, extras, and alternatives.

#pip#package#cli#terminalupdated 05-31-2026

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

bash
pip install typer

Output: (none — exits 0 on success). Installs typer, click, rich, and shellingham by default on recent releases.

bash
uv add typer

Output: dependency resolved + added to pyproject.toml

bash
poetry add typer

Output: updated lockfile + virtualenv install

bash
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.x series. Despite the 0. 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 into typer (lean) and typer[all] (with rich + shellingham). From 0.9 onward, those extras became default dependencies, and the slim extra exists for the lean install.
  • Pin to typer>=0.12,<1 to avoid accidental upgrades when 1.0 lands.

Package metadata

  • Maintainer: Sebastián Ramírez (tiangolo on 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) — includes click, rich, and shellingham. Sufficient for almost every use case.
  • typer[slim] — strips out rich and shellingham. 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, tables
  • shellingham — auto-detect the user's shell for typer --install-completion

Alternatives

PackageTrade-off
clickThe 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.
fireReflective CLI generation from any Python object. Less polished UX; good for ad-hoc exposure.
cycloptsNewer 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

  1. typer vs typer-cli. typer-cli was a separate package providing a typer command for running scripts without an entry point. It is deprecated; modern Typer ships the runner inside the main package.
  2. Annotated[type, typer.Option(...)] is the modern syntax. Older docs show name: str = typer.Option("default", "--name") — that still works, but Typer 0.9+ prefers name: Annotated[str, typer.Option("--name")] = "default" to separate type information from CLI metadata. Mypy and IDE refactors handle the Annotated form better.
  3. Rich-vs-classic output toggled by env var. Set _TYPER_FORCE_DISABLE_TERMINAL=1 to suppress rich rendering (helpful in CI logs and unit tests). Set _TYPER_COMPLETE_TESTING=1 when testing completion scripts.
  4. typer.Exit() not sys.exit(). Inside commands, raise typer.Exit(code=1) so Typer's CliRunner and exception hooks see the exit cleanly. Calling sys.exit() skips Rich's exception formatting.
  5. 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.
  6. Optional Annotated arguments require careful default placement. With the Annotated form, the Python default value goes after the type annotation (= "default"), not inside typer.Option(...). Putting the default inside the option metadata still works but conflicts with Python's positional-argument rules in some cases.
  7. Auto-completion installation writes to user's shell rc. --install-completion appends 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().

python
# 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})")
python
# 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.

python
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.

python
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.

python
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

toml
# 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.

bash
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:

dockerfile
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.

bash
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.

python
# 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}")
python
# 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:

bash
# 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:

text
# 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:

python
# 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.

python
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=1 in the test env disables Rich rendering. Output is then plain ASCII, easier to assert on.
  • Async commands — wrap with asyncio.run inside the command body (see Recipes); the runner sees a sync function and tests it normally.
  • mix_stderr=False — passed to CliRunner(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 into typer.prompt() flows: runner.invoke(app, ["init"], input="alicedev\n8080\n").

Troubleshooting common errors

SymptomCauseFix
Missing argument 'NAME' for a parameter that has a defaultAnnotated 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 optionEither 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 shellshellingham 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 subcommandsadd_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 exceptionDefault 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 texttyper.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 Noneasync def used directly as a Typer command — Typer doesn't await.Wrap with a sync function that calls asyncio.run(...).

See also