pyguides

Modern CLIs with Typer

If you’ve built CLIs with Click and wished the boilerplate would disappear, Typer is worth knowing. Typer sits on top of Click and uses Python type annotations to handle argument parsing, validation, help generation, and shell completion automatically. Your function signatures become your CLI interface.

Why Typer?

Typer leans on Python’s type system so you don’t have to write decorator soup. Instead of annotating parameters with decorators, you write plain Python type hints and Typer infers whether something is a required argument or an optional flag from the function signature itself.

A bare str parameter without a default becomes a required positional argument. Add a default value and it becomes an option with that default. A bool parameter with a default becomes a flag that toggles that behavior.

This approach means less noise in your code. The CLI definition is the function. When you read the function, you see the interface.

Typer also integrates with Click, so nothing is hidden. You can drop down to Click objects when you need custom behavior.

Installation

pip install typer

For full shell completion support across Bash, Zsh, Fish, and PowerShell, install the complete package:

pip install "typer[all]"

Python 3.8+ is required. If you’re on an older version, upgrade first.

Your First Command

The minimal Typer app looks like this:

import typer

app = typer.Typer()

@app.command()
def greet(name: str):
    print(f"Hello, {name}!")

if __name__ == "__main__":
    app()

Run it:

$ python greet.py Alice
Hello, Alice!

$ python greet.py --help
Usage: greet.py NAME

Options:
  --help  Show this message and exit.

Typer reads name: str and automatically treats it as a required positional argument called NAME. No decorators, no click.Argument(), just a type hint.

Arguments vs Options

Typer distinguishes between arguments and options based on whether a parameter has a default value:

ParameterCLI behavior
name: strRequired positional argument
name: str = "World"Option with default
active: bool = FalseBoolean flag (--active / --no-active)
ports: list[int]Multiple values (--ports 80 443)

Arguments appear in a fixed order and are required by default. Options use flags and are optional. The distinction matters for user experience: positional arguments work well when there are one or two clearly required values, while options scale better as you add configuration flags.

Adding Options with Help Text

Use Annotated to attach metadata to parameters without changing the type:

from typing import Annotated
import typer

app = typer.Typer()

@app.command()
def serve(
    host: Annotated[str, typer.Option(help="Host to bind to")] = "127.0.0.1",
    port: Annotated[int, typer.Option(help="Port to bind to")] = 8000,
    verbose: bool = False,
):
    print(f"Starting server on {host}:{port}, verbose={verbose}")

if __name__ == "__main__":
    app()
$ python serve.py --help
 Usage: serve.py [OPTIONS]

 Options:
   --host TEXT     Host to bind to  [default: 127.0.0.1]
   --port INTEGER  Port to bind to  [default: 8000]
   --verbose / --no-verbose
   --help          Show this message and exit.

$ python serve.py --port 9000
Starting server on 127.0.0.1:9000, verbose=False

Boolean flags generate paired options automatically. --verbose sets it to True, --no-verbose sets it to False. This is useful for boolean toggles where you want an explicit way to turn something off as well as on.

The typer.Option call takes several useful parameters beyond help. You can set show_default=False to hide the default value from help text, is_eager=True to process the option before other commands (handy for something like --verbose that should be parsed globally), and show_envvar=True to display which environment variable feeds the option when one is set.

Subcommands

Group related commands under a single app using add_typer():

import typer

app = typer.Typer()
admin_app = typer.Typer()
app.add_typer(admin_app, name="admin")

@app.command()
def serve(port: int = 8000):
    print(f"Serving on port {port}")

@admin_app.command()
def create_user(name: str, admin: bool = False):
    role = "admin" if admin else "user"
    print(f"Creating {role}: {name}")

if __name__ == "__main__":
    app()
$ python main.py admin create-user --name Alice
Creating user: Alice

$ python main.py admin create-user --name Bob --admin
Creating admin: Bob

$ python main.py serve --port 9000
Serving on port 9000

Each nested Typer becomes a subcommand. The name you pass to add_typer() is the subcommand name in the CLI.

Subcommands are a natural fit for tools with distinct operational modes. A deployment tool might have deploy, rollback, and status as top-level commands, while each of those might have their own subcommands. For large CLIs, organizing commands into nested groups keeps the interface discoverable.

Input Validation with Callbacks

Use the callback parameter on an Option to validate or transform input before it reaches your function:

import typer
from typing import Annotated

app = typer.Typer()

def validate_port(port: int) -> int:
    if port < 1 or port > 65535:
        raise typer.BadParameter("Port must be between 1 and 65535")
    return port

@app.command()
def serve(
    port: Annotated[int, typer.Option(callback=validate_port)] = 8000,
):
    print(f"Serving on port {port}")

if __name__ == "__main__":
    app()
$ python serve.py --port 9000
Serving on port 9000

$ python serve.py --port 70000
Error: Port must be between 1 and 65535

The callback receives the raw value, validates it, and either returns the transformed value or raises typer.BadParameter with a user-friendly message.

Callbacks are also useful for transformations. A callback could convert a shorthand date string into a full datetime object, normalize file paths, or resolve environment variable references. The callback runs after the type conversion but before the function is called, so you get a fully validated value in your handler.

Reading Environment Variables

The envvar parameter on Option makes Typer read from an environment variable if the CLI flag isn’t provided:

from typing import Annotated
import typer

app = typer.Typer()

@app.command()
def serve(
    api_key: Annotated[str, typer.Option(envvar="API_KEY")] = "",
):
    print(f"Using API key: {api_key[:4]}..." if api_key else "No API key")

if __name__ == "__main__":
    app()
$ API_KEY=secret123 python serve.py
Using API key: secu...

$ python serve.py --api-key another-key
Using API key: anot...

If the flag is not provided, Typer checks the environment. If neither exists, it falls back to the default.

This pattern works well for secrets and configuration that differs between environments. Your local .env file or CI/CD pipeline sets the values, and the CLI reads them automatically without hardcoding or command-line flags for every run.

Shell Completion

Typer enables shell completion automatically. To add custom completion for an option’s values, pass a callable to shell_complete:

from typing import Annotated
import typer

app = typer.Typer()

USERS = ["alice", "bob", "charlie", "admin"]

def complete_user(incomplete: str) -> list[str]:
    return [u for u in USERS if u.startswith(incomplete)]

@app.command()
def greet(
    user: Annotated[str, typer.Option(shell_complete=complete_user)] = "alice",
):
    print(f"Hello, {user}!")

if __name__ == "__main__":
    app()

With this, pressing Tab after typing greet.py --user a shows alice and admin as completions.

The completion callable receives whatever the user has typed so far and returns a list of matching candidates. You can pull completions from a database, API, filesystem, or any other source. The function must return a list of strings — Typer handles the shell integration.

Sharing State with Context

The typer.Context object lets commands access shared state:

import typer

app = typer.Typer()

@app.command()
def main(ctx: typer.Context):
    ctx.ensure_object(dict)
    ctx.obj["version"] = "1.0"
    greet(ctx)

@app.command()
def greet(ctx: typer.Context):
    version = ctx.obj.get("version", "unknown")
    print(f"Running version {version}")

if __name__ == "__main__":
    app()
$ python main.py greet
Running version 1.0

The context flows through all commands in the app. Any command can read or write to ctx.obj.

One gotcha: ctx.obj is None by default if you haven’t set it. If a subcommand expects ctx.obj to be a dictionary and it hasn’t been initialized, you’ll get an AttributeError. The fix is to call ctx.ensure_object(dict) in the entry command, or pass obj={} when calling app().

Rich Help Output

When Rich is installed, Typer automatically formats help output with colors and spacing. You can control this behavior:

app = typer.Typer(pretty=False)  # disable Rich formatting

Or group commands under a custom panel:

app = typer.Typer(
    rich_help_panel="Utility Commands",
)

Rich formatting kicks in automatically when the rich package is present in your environment. Users see colored help text, properly aligned option tables, and wrapped text that respects terminal width. This makes your CLI feel polished without any extra work.

Exiting Gracefully

Typer raises typer.Exit on normal termination and typer.Abort on errors:

from typing import Annotated
import typer

app = typer.Typer()

@app.command()
def fail():
    print("Something went wrong")
    raise typer.Exit(code=1)

@app.command()
def interactive():
    print("Press Ctrl+C to abort")
    raise typer.Abort()

if __name__ == "__main__":
    app()

Use typer.Exit for expected error conditions — validation failures, missing files, user cancellation. Reserve typer.Abort for situations where something has gone wrong and the user should know. Abort prints “Aborted!” to stderr and exits with code 1.

A Practical Example

Here’s a slightly more realistic CLI that combines several features: options, environment variable fallbacks, validation callbacks, and subcommands.

from typing import Annotated
import typer
import os

app = typer.Typer()
manage_app = typer.Typer()
app.add_typer(manage_app, name="manage")

def validate_replicas(replicas: int) -> int:
    if replicas < 1 or replicas > 10:
        raise typer.BadParameter("Replicas must be between 1 and 10")
    return replicas

@app.command()
def start(
    name: Annotated[str, typer.Option(help="Service name")] = "myapp",
    replicas: Annotated[int, typer.Option(callback=validate_replicas)] = 3,
    debug: bool = False,
):
    print(f"Starting {name} with {replicas} replicas (debug={debug})")

@manage_app.command()
def scale(
    replicas: Annotated[int, typer.Option(callback=validate_replicas)],
):
    print(f"Scaling to {replicas} replicas")

if __name__ == "__main__":
    app()

This demonstrates how a single script can expose a clean, declarative interface. The validate_replicas callback enforces a domain constraint. The --debug flag needs no help text because the flag name is self-explanatory. Subcommands keep related operations grouped.

See Also