Intermediate
You have a Python script that works great — but only you know how to run it. The arguments are hardcoded, the help text is a comment inside the file, and every time a colleague asks how to use it you end up writing a three-paragraph Slack message. Building a proper command-line interface turns that script into a tool other people can actually use.
Typer is a modern Python library for building CLIs with almost no boilerplate. It uses Python type hints to define arguments and options automatically, generates beautiful help text, and supports subcommands for complex tools. It is built on top of Click but removes most of the ceremony. Install it with pip install typer[all] — the [all] extra includes Rich for coloured output and the shell completion helper.
In this article you will learn how to create single-command CLIs, add optional flags and required arguments, build multi-command apps with subcommands, validate inputs with callbacks, and package everything into a distributable tool. By the end you will have a working file-management CLI that demonstrates every pattern Typer supports.
Building a Typer CLI: Quick Example
Here is the shortest possible Typer app — a greeting command that takes a name and an optional times flag. Copy this into a file and run it to see Typer in action immediately.
# greet.py
import typer
app = typer.Typer()
@app.command()
def greet(name: str, times: int = 1):
"""Greet someone by name."""
for _ in range(times):
typer.echo(f"Hello, {name}!")
if __name__ == "__main__":
app()
Output:
$ python greet.py Alice
Hello, Alice!
$ python greet.py Alice --times 3
Hello, Alice!
Hello, Alice!
Hello, Alice!
$ python greet.py --help
Usage: greet.py [OPTIONS] NAME
Greet someone by name.
Arguments:
NAME [required]
Options:
--times INTEGER [default: 1]
--help Show this message and exit.
The @app.command() decorator registers the function as a CLI command. Typer reads the type hints (name: str, times: int = 1) and automatically creates a required positional argument and an optional integer flag. The docstring becomes the help text — no separate help string needed. Further sections show how to build on this foundation for real-world tools.
What Is Typer and Why Use It?
Typer is a library that maps Python function signatures to CLI interfaces. You write a normal Python function with type-hinted parameters, and Typer generates the argument parser, validation logic, and help text for you. This is different from Python’s built-in argparse, which requires you to define each argument imperatively in a separate setup block.
Typer sits on top of Click, a mature and battle-tested CLI library. This means it inherits Click’s reliability while adding a cleaner API. The key advantage over raw Click is that you never have to write @click.option('--name', '-n', type=str, help='...') decorators — the function’s own type hints carry all of that information.
| Feature | argparse | Click | Typer |
|---|---|---|---|
| Argument definition | Imperative setup | Decorators | Type hints |
| Help text source | Explicit strings | Explicit strings | Docstrings |
| Subcommands | Subparsers | Groups | Multiple commands |
| Type validation | Manual | Type param | Automatic via hints |
| Coloured output | No | echo + style | Rich integration |
| Python version | stdlib | 3.7+ | 3.7+ |
The trade-off is that Typer is not in the standard library and adds a dependency. For simple scripts that need to stay dependency-free, argparse is still a reasonable choice. For anything beyond a single command, Typer saves substantial time and produces better help text automatically.
Arguments, Options, and Defaults
Typer distinguishes between arguments (positional, required by default) and options (named flags, optional by default). This distinction comes directly from how you define the function parameter: a bare type hint creates an argument, while a default value creates an option.
# file_info.py
import typer
from pathlib import Path
app = typer.Typer()
@app.command()
def info(
filepath: Path,
verbose: bool = False,
encoding: str = "utf-8",
):
"""Show information about a file."""
if not filepath.exists():
typer.echo(f"Error: {filepath} does not exist.", err=True)
raise typer.Exit(code=1)
size = filepath.stat().st_size
typer.echo(f"File: {filepath}")
typer.echo(f"Size: {size} bytes")
if verbose:
typer.echo(f"Encoding hint: {encoding}")
typer.echo(f"Suffix: {filepath.suffix}")
if __name__ == "__main__":
app()
Output:
$ python file_info.py README.md
File: README.md
Size: 1420 bytes
$ python file_info.py README.md --verbose
File: README.md
Size: 1420 bytes
Encoding hint: utf-8
Suffix: .md
$ python file_info.py missing.txt
Error: missing.txt does not exist.
# Exit code 1
Notice that filepath: Path is a positional argument because it has no default value, and Typer automatically validates that the string provided on the command line is a valid path object. The verbose: bool = False parameter becomes a --verbose flag that Typer pairs with an automatic --no-verbose counterpart. Calling raise typer.Exit(code=1) exits the program with an error code that shell scripts and CI pipelines can detect.
Adding Help Text and Validation with Annotated
For production tools you need more control: per-argument help text, value constraints, and custom error messages. Typer uses Python’s Annotated type to attach this metadata directly to the parameter type hint without changing the function signature’s readability.
# convert.py
import typer
from typing import Annotated
app = typer.Typer()
@app.command()
def convert(
value: Annotated[float, typer.Argument(help="The number to convert")],
from_unit: Annotated[str, typer.Option(help="Source unit (km, miles, kg, lbs)")] = "km",
to_unit: Annotated[str, typer.Option(help="Target unit")] = "miles",
):
"""Convert between common measurement units."""
conversions = {
("km", "miles"): 0.621371,
("miles", "km"): 1.60934,
("kg", "lbs"): 2.20462,
("lbs", "kg"): 0.453592,
}
key = (from_unit.lower(), to_unit.lower())
if key not in conversions:
typer.echo(f"Unsupported conversion: {from_unit} -> {to_unit}", err=True)
raise typer.Exit(code=1)
result = value * conversions[key]
typer.echo(f"{value} {from_unit} = {result:.4f} {to_unit}")
if __name__ == "__main__":
app()
Output:
$ python convert.py 10
10.0 km = 6.2137 miles
$ python convert.py 150 --from-unit lbs --to-unit kg
150.0 lbs = 68.0388 kg
$ python convert.py --help
Usage: convert.py [OPTIONS] VALUE
Convert between common measurement units.
Arguments:
VALUE The number to convert [required]
Options:
--from-unit TEXT Source unit (km, miles, kg, lbs) [default: km]
--to-unit TEXT Target unit [default: miles]
--help Show this message and exit.
The Annotated wrapper lets you keep the function signature clean while embedding the CLI metadata (help text, default display, constraints) right alongside the type. Typer automatically converts option names from Python’s snake_case to CLI’s --kebab-case convention, so from_unit becomes --from-unit in the help text and on the command line.
Building Multi-Command Apps with Subcommands
Real tools like git, docker, and pip are structured as a main command with multiple subcommands (git commit, docker build, etc.). Typer handles this through the add_typer() method, which nests a child Typer app under a name in a parent app. This keeps each subcommand group in its own module, making large CLIs easy to maintain.
# notes_app.py
import typer
from datetime import datetime
app = typer.Typer(help="A simple notes manager.")
notes_app = typer.Typer(help="Manage your notes.")
app.add_typer(notes_app, name="notes")
# In-memory storage for this demo
_notes: list[dict] = []
@notes_app.command("add")
def add_note(text: str, tag: str = "general"):
"""Add a new note."""
note = {"id": len(_notes) + 1, "text": text, "tag": tag, "created": datetime.now().strftime("%Y-%m-%d")}
_notes.append(note)
typer.echo(f"Added note #{note['id']}: {text!r} [{tag}]")
@notes_app.command("list")
def list_notes(tag: str = ""):
"""List all notes, optionally filtered by tag."""
shown = [n for n in _notes if not tag or n["tag"] == tag]
if not shown:
typer.echo("No notes found.")
return
for note in shown:
typer.echo(f"[{note['id']}] ({note['tag']}) {note['text']}")
@app.command()
def version():
"""Show the app version."""
typer.echo("notes-app v1.0.0")
if __name__ == "__main__":
app()
Output:
$ python notes_app.py --help
Usage: notes_app.py [OPTIONS] COMMAND [ARGS]...
A simple notes manager.
Commands:
notes Manage your notes.
version Show the app version.
$ python notes_app.py notes add "Buy groceries" --tag shopping
Added note #1: 'Buy groceries' [shopping]
$ python notes_app.py notes add "Read Python docs" --tag learning
Added note #2: 'Read Python docs' [learning]
$ python notes_app.py notes list
[1] (shopping) Buy groceries
[2] (learning) Read Python docs
$ python notes_app.py notes list --tag learning
[2] (learning) Read Python docs
Each subcommand module defines its own Typer() instance and its own set of commands. The parent app wires them together with add_typer(). This structure scales cleanly — you can have notes, tags, and export subcommand groups, each in separate files, all composed into one top-level CLI without any single file growing unwieldy.
Input Validation with Callbacks
Some validation logic is too complex for a type hint alone — for example, ensuring a port number is in a valid range, or that a file has the right extension. Typer supports option callbacks: functions that run immediately after an argument is parsed, either to transform its value or to reject it with a helpful error message.
# server.py
import typer
from typing import Annotated
app = typer.Typer()
def validate_port(value: int) -> int:
if not (1 <= value <= 65535):
raise typer.BadParameter(f"Port must be between 1 and 65535, got {value}")
if value < 1024:
typer.echo(f"Warning: port {value} requires root privileges.", err=True)
return value
@app.command()
def serve(
host: str = "127.0.0.1",
port: Annotated[int, typer.Option(callback=validate_port, help="Port to listen on")] = 8000,
reload: bool = False,
):
"""Start the development server."""
typer.echo(f"Starting server on {host}:{port}")
if reload:
typer.echo("Auto-reload enabled.")
if __name__ == "__main__":
app()
Output:
$ python server.py
Starting server on 127.0.0.1:8000
$ python server.py --port 99999
Error: Invalid value for '--port': Port must be between 1 and 65535, got 99999
$ python server.py --port 80
Warning: port 80 requires root privileges.
Starting server on 127.0.0.1:80
The callback receives the parsed value and either returns the (possibly transformed) value or raises typer.BadParameter with a descriptive message. Typer catches BadParameter and formats it as an error line that matches the rest of the help output, so users see consistent error messages rather than Python tracebacks.
Real-Life Example: File Organiser CLI
The following CLI tool organises files in a directory by grouping them into subfolders based on their extension. It supports a dry-run mode for safe previewing and demonstrates argument types, options, subcommands, and exit codes all working together.
# organiser.py
import typer
from pathlib import Path
from typing import Annotated
import shutil
app = typer.Typer(help="Organise files in a directory by extension.")
EXTENSION_MAP = {
".jpg": "images", ".jpeg": "images", ".png": "images", ".gif": "images",
".mp4": "videos", ".mov": "videos", ".avi": "videos",
".pdf": "documents", ".docx": "documents", ".txt": "documents",
".py": "code", ".js": "code", ".ts": "code", ".html": "code",
".zip": "archives", ".tar": "archives", ".gz": "archives",
}
@app.command()
def organise(
directory: Annotated[Path, typer.Argument(help="Directory to organise")],
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without moving files"),
verbose: bool = False,
):
"""Move files into subfolders grouped by file type."""
if not directory.is_dir():
typer.echo(f"Error: {directory} is not a directory.", err=True)
raise typer.Exit(code=1)
files = [f for f in directory.iterdir() if f.is_file()]
if not files:
typer.echo("No files found.")
return
moved = 0
skipped = 0
for f in files:
folder_name = EXTENSION_MAP.get(f.suffix.lower(), "misc")
destination = directory / folder_name / f.name
if dry_run:
typer.echo(f"[dry-run] Would move: {f.name} -> {folder_name}/")
moved += 1
else:
destination.parent.mkdir(exist_ok=True)
shutil.move(str(f), str(destination))
if verbose:
typer.echo(f"Moved: {f.name} -> {folder_name}/")
moved += 1
action = "Would move" if dry_run else "Moved"
typer.echo(f"{action} {moved} file(s). Skipped {skipped}.")
@app.command()
def stats(directory: Path):
"""Show file type statistics for a directory."""
if not directory.is_dir():
typer.echo(f"Error: {directory} is not a directory.", err=True)
raise typer.Exit(code=1)
counts: dict[str, int] = {}
for f in directory.iterdir():
if f.is_file():
ext = f.suffix.lower() or "(no extension)"
counts[ext] = counts.get(ext, 0) + 1
if not counts:
typer.echo("No files found.")
return
typer.echo(f"File types in {directory}:")
for ext, count in sorted(counts.items(), key=lambda x: -x[1]):
typer.echo(f" {ext:15s} {count} file(s)")
if __name__ == "__main__":
app()
Output:
$ python organiser.py ~/Downloads --dry-run
[dry-run] Would move: report.pdf -> documents/
[dry-run] Would move: photo.jpg -> images/
[dry-run] Would move: archive.zip -> archives/
Would move 3 file(s). Skipped 0.
$ python organiser.py stats ~/Downloads
File types in /home/user/Downloads:
.pdf 12 file(s)
.jpg 8 file(s)
.zip 3 file(s)
This example shows Typer's practical strengths: the --dry-run flag uses typer.Option with a custom flag name, the second subcommand (stats) is added automatically just by decorating a second function, and error handling uses the standard typer.Exit(code=1) pattern. You can extend this tool by adding a config subcommand that reads a TOML file to customise the EXTENSION_MAP, or a --undo flag that reads a log file of previous moves.
Frequently Asked Questions
What is the difference between Typer and Click?
Typer is built on top of Click and shares its runtime behaviour. The difference is in the authoring experience. Click requires decorators like @click.option('--count', type=int, default=1) to define each argument, while Typer infers the same information from the function's type hints. For most projects Typer is easier to write and read; for advanced use cases that need Click's lower-level features (custom parameter types, multi-value options), you can mix Typer and Click decorators.
How do I install Typer and what does typer[all] include?
Install the base package with pip install typer. The [all] extra (pip install typer[all]) adds two optional dependencies: Rich for coloured terminal output and pretty error formatting, and shellingham for automatic shell completion detection. For production CLI tools the [all] install is recommended because Rich makes the help text significantly more readable.
How do I make an option required instead of optional?
Set the default to ... (Ellipsis): name: str = typer.Option(...). The three dots tell Typer that no default exists and the flag must be provided. Typer will show it in the help text as [required] and exit with an error message if the user omits it. Alternatively, use Annotated[str, typer.Option()] without a default value in the function signature.
How do I restrict an option to a fixed set of values?
Use a Python Enum as the type hint. Define an enum class with class Format(str, enum.Enum): json = "json"; csv = "csv"; table = "table", then type the parameter as format: Format = Format.table. Typer automatically validates that the provided value is one of the enum members and displays the choices in the help text.
How do I write tests for Typer CLI commands?
Use the typer.testing.CliRunner class (which wraps Click's test runner). Create a runner with runner = CliRunner(), then invoke your app with result = runner.invoke(app, ["subcommand", "--option", "value"]). Check result.exit_code for the exit status and result.stdout for the captured output. This lets you write unit tests for CLI commands without spawning a subprocess.
How do I add shell completion to a Typer app?
With typer[all] installed, Typer automatically adds a --install-completion flag to every app. Running python myapp.py --install-completion detects the user's shell (bash, zsh, fish, or PowerShell) and installs the completion script to the appropriate location. Users then get tab-completion for subcommands, options, and file-path arguments without any extra code.
Conclusion
Typer makes building Python CLIs feel as natural as writing a regular function. You have seen how type hints automatically create arguments and options, how docstrings become help text, how add_typer() composes multiple subcommand groups into a single tool, and how callbacks enforce validation rules before your main function runs. The file organiser example brought all of these together in a tool you could ship today.
The next step is to package your Typer app so others can install it. Add a pyproject.toml with a [project.scripts] entry pointing to your app's app() call, and users can run your tool by name after pip install .. Combine that with a virtual environment managed by uv and you have a professional-grade Python tool from a handful of annotated functions.
For the full Typer reference, including password prompts, progress bars, and custom parameter types, see the official Typer documentation.