mrkeyoor.com_
Wed 05 Aug 05:03 UTC
PyPICLI & Toolingupdated 05 Aug 2026

typer

Typer is a Python library for building command-line apps from type hints, made by the FastAPI author and built on top of Click. You write a normal function with typed parameters and Typer turns it into a CLI with argument parsing, validation, generated --help, error messages and shell completion. It scales from typer.run(main) on a single function up to nested subcommand trees, and it ships a typer command that can run even plain un-decorated Python scripts as CLIs. Output is styled with Rich when it is installed.

Verdict

The nicest developer experience for Python CLIs today, especially if you know FastAPI. Accept the 0.x churn and the extra dependencies; for stdlib-only scripts argparse still earns its keep.

API stability3/5Still 0.x after six years; the move to Annotated-based declarations changed the recommended idiom and older examples no longer match the docs. Changes are managed with deprecation periods, but they happen.
Docs5/5typer.tiangolo.com is a full tutorial in the FastAPI style: every feature has a runnable example with the expected terminal output shown inline.
Maintenance4/5Active (pushed within a day of this review, regular releases) and now under the fastapi GitHub org with team members, but direction still concentrates on one lead maintainer and the issue backlog is substantial.
Ecosystem4/5Sits on Click so its ecosystem mostly applies, testing via CliRunner is built in, and Rich integration is first-class; native Typer-specific plugins are few because Click fills that role.

Use it if

  • You are building a CLI with options, arguments and subcommands and want parsing, validation and --help generated from type hints instead of written by hand
  • You already think in FastAPI idioms; Typer uses the same Annotated-based declaration style and the same documentation voice
  • You want free shell autocompletion for bash, zsh, fish and PowerShell via --install-completion
  • You want pretty terminal output and error panels without wiring Rich yourself
Skip it if

Setup reality

pip install typer gets you the library plus rich and shellingham, and a hello-world CLI is genuinely three lines. The friction: the docs now assume uv and its examples run through the typer command, which confuses people who just want python main.py; shell completion needs a per-shell install step (--install-completion) and only works properly for installed packages, not loose scripts; boolean options auto-generate --flag/--no-flag pairs that surprise first-timers; and because the project is pre-1.0 you will find two option-declaration styles in the wild, the old typer.Option default-value style and the current Annotated style, and mixing them in one signature is an error-prone mess.

Patterns

Smallest possible CLIsingle-command

import typer


def main(name: str):
    print(f"Hello {name}")


if __name__ == "__main__":
    typer.run(main)

typer.run wraps one function as a complete CLI; the moment you need a second command, switch to typer.Typer().

App with multiple commandsmulti-command

import typer

app = typer.Typer()


@app.command()
def create(name: str):
    print(f"Creating {name}")


@app.command()
def delete(name: str):
    print(f"Deleting {name}")


if __name__ == "__main__":
    app()

Command names come from function names with underscores turned into hyphens; a single @app.command still requires the command name on the CLI unless it is the only one.

Option with help text (current Annotated style)options-annotated

from typing import Annotated
import typer


def main(
    name: str,
    greeting: Annotated[str, typer.Option(help="Greeting word")] = "Hello",
):
    print(f"{greeting} {name}")

Annotated is the recommended style; the old greeting: str = typer.Option("Hello") form still works but the docs have moved on.

Required and documented positional argumentsarguments

from pathlib import Path
from typing import Annotated
import typer


def main(
    src: Annotated[Path, typer.Argument(exists=True, readable=True,
                                        help="Input file")],
):
    print(src.read_text()[:100])

Path parameters get free validation: exists=True makes Typer error out before your function runs.

Boolean flagsboolean-flags

from typing import Annotated
import typer


def main(
    verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
    force: bool = False,  # generates --force / --no-force
):
    if verbose:
        print("verbose on")

A bare bool option auto-creates a --x/--no-x pair; pass explicit flag names to get a plain switch instead.

Prompt for input and passwordsprompts-secrets

from typing import Annotated
import typer


def main(
    user: Annotated[str, typer.Option(prompt=True)],
    password: Annotated[str, typer.Option(prompt=True, hide_input=True,
                                          confirmation_prompt=True)],
):
    print(f"Logging in {user}")

Prompts only fire when the option is missing from the command line, so scripts can still pass --password (with the usual shell-history caveat).

Nested subcommands (git-style)subcommand-groups

import typer

app = typer.Typer()
users_app = typer.Typer()
app.add_typer(users_app, name="users")


@users_app.command("create")
def users_create(name: str):
    print(f"user {name} created")

# CLI: mytool users create ada

Each Typer instance can carry its own callback for group-level options; help text nests correctly for free.

Fail with a proper exit codeexit-codes

import typer


def main(path: str):
    if not path.endswith(".csv"):
        typer.secho("Only CSV files supported", fg=typer.colors.RED, err=True)
        raise typer.Exit(code=1)
    print("ok")

raise typer.Exit, do not call sys.exit inside commands; typer.Abort() prints Aborted and exits 1 for cancel flows.

App-level options via callbackglobal-options

import typer

app = typer.Typer()
state = {"verbose": False}


@app.callback()
def common(verbose: bool = False):
    """My tool. Global flags go here."""
    state["verbose"] = verbose


@app.command()
def run():
    if state["verbose"]:
        print("verbose mode")

Callback options must come before the command on the CLI: mytool --verbose run, not mytool run --verbose.

Progress bar over an iterableprogress-bar

import time
import typer


def main():
    items = range(100)
    with typer.progressbar(items, label="Processing") as progress:
        for _ in progress:
            time.sleep(0.01)

Fine for simple loops; for spinners, multiple bars or fancy rendering use rich.progress directly, it is already installed.

Test a CLI without spawning a processtesting

from typer.testing import CliRunner
from mytool.main import app

runner = CliRunner()


def test_create():
    result = runner.invoke(app, ["create", "ada"])
    assert result.exit_code == 0
    assert "Creating ada" in result.output

CliRunner needs a typer.Typer app object; if you only used typer.run, wrap the function in an app to make it testable.

Alternatives

PackageRegistryPick it when
clickPyPIYou want the mature, decorator-based foundation Typer is built on, with more control and a huge plugin ecosystem
cycloptsPyPIYou like the type-hint approach but want stricter typing behavior and some Typer paper cuts fixed
firePyPIZero-effort CLI over an existing module or class for internal tooling; not for polished user-facing CLIs