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:
| Parameter | CLI behavior |
|---|---|
name: str | Required positional argument |
name: str = "World" | Option with default |
active: bool = False | Boolean 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
- /tutorials/python-cli/cli-click-framework/ — the Click library Typer builds on, and the first tutorial in this series
- /guides/argparse-guide/ — Python’s built-in CLI parser for simpler tools
- /guides/env-variables/ — handling configuration via environment variables in CLIs