Intermediate
Command-line interfaces (CLIs) are the backbone of modern development workflows. From package managers to deployment tools, every developer relies on well-designed CLI applications. If you’ve ever dreamed of building the next popular dev tool but found the existing CLI frameworks overwhelming, Python has an elegant solution: Typer. Typer combines the power of type hints with an intuitive API that makes building professional CLIs feel like writing regular Python functions.
The beauty of Typer lies in its simplicity wrapped in sophistication. Unlike older frameworks that require boilerplate configuration, Typer leverages Python’s type annotations to automatically generate help text, validate inputs, and handle command parsing. If you already know how to write Python functions, you already know how to write Typer CLI apps. No special decorators or configuration files needed.
This tutorial walks you through everything you need to build production-ready CLI tools. We’ll start with the fundamentals, explore advanced features like interactive prompts and colored output, and then build a complete file organizer application that demonstrates all the concepts in action. By the end, you’ll have a reusable template for any CLI project.

Quick Example: Your CLI in 10 Lines
Before diving into the theory, let’s see Typer in action. Here’s the absolute minimum code needed to create a working CLI tool:
# hello_cli.py
import typer
app = typer.Typer()
@app.command()
def hello(name: str):
typer.echo(f"Hello, {name}!")
if __name__ == "__main__":
app()
Output:
$ python hello_cli.py --help
Usage: hello_cli.py [OPTIONS] COMMAND [ARGS]...
Commands:
hello
$ python hello_cli.py hello Alice
Hello, Alice!
That’s it. No configuration, no argument parsing setup, no manual help text. Typer inferred everything from the function signature. The name parameter automatically became a command argument, and Typer generated professional help documentation instantly. This is the Typer philosophy: sensible defaults with maximum productivity.
What Is Typer and Why Use It
Typer is a modern Python library built on top of Click that simplifies CLI development. It’s created by the same developer who built FastAPI, and it brings FastAPI’s elegance to the command line. Rather than forcing you to learn a new syntax or remember decorator parameters, Typer uses standard Python type hints to express CLI intent.
To understand why Typer matters, let’s compare it with other popular CLI frameworks:
| Feature | argparse | Click | Typer |
|---|---|---|---|
| Verbosity | High (10+ lines for simple CLI) | Medium (5-7 lines) | Low (3-5 lines) |
| Type Hints Support | None | Partial | Full Native Support |
| Auto-generated Help | Basic | Good | Excellent |
| Learning Curve | Steep | Moderate | Shallow |
| Input Validation | Manual | Custom Types | Type Hints |
| IDE Autocompletion | Poor | Good | Excellent |
Typer’s primary advantage is reducing cognitive load. You write Python functions that look like regular functions, and Typer handles the CLI machinery. Combined with modern IDE support, this means better code completion, fewer runtime surprises, and more time spent on your application logic instead of CLI plumbing.
Installing Typer
Getting started with Typer requires just one command. We’ll install the complete version with all optional dependencies to unlock advanced features like colored output:
# Install Typer with all extras
pip install "typer[all]"
What gets installed:
$ pip list | grep -i typer
typer 0.9.0
click 8.1.7
rich 13.7.0
shellingham 1.5.4
The [all] extra installs Rich (for colored output and tables), shellingham (for shell completion), and other utilities. If you want a minimal install with just the essentials, use pip install typer instead. Verify the installation works:
$ python -c "import typer; print(typer.__version__)"
0.9.0
Your First Typer Command
Now that Typer is installed, let’s build a slightly more complex application. Understanding command structure is crucial because every Typer app follows the same pattern: create an app object, decorate functions with @app.command(), and invoke it at the bottom.
# weather_cli.py
import typer
app = typer.Typer(help="Simple weather information tool")
@app.command()
def current(city: str):
"""Get current weather for a city."""
typer.echo(f"Weather in {city}: Sunny, 72F")
@app.command()
def forecast(city: str, days: int = 7):
"""Get weather forecast for upcoming days."""
typer.echo(f"Forecast for {city} ({days} days):")
for i in range(1, days + 1):
typer.echo(f" Day {i}: Partly cloudy")
if __name__ == "__main__":
app()
Output:
$ python weather_cli.py --help
Usage: weather_cli.py [OPTIONS] COMMAND [ARGS]...
Simple weather information tool
Options:
--help Show this message and exit.
Commands:
current Get current weather for a city.
forecast Get weather forecast for upcoming days.
$ python weather_cli.py current London
Weather in London: Sunny, 72F
$ python weather_cli.py forecast Paris --days 3
Forecast for Paris (3 days):
Day 1: Partly cloudy
Day 2: Partly cloudy
Day 3: Partly cloudy
Notice how Typer automatically converted the docstrings into help text, made days optional because it has a default value, and even inferred that it should be passed as --days flag. This is zero-configuration development.
Adding Arguments and Options
CLI parameters come in two flavors: arguments (positional, required) and options (named, optional with defaults). Understanding this distinction helps design intuitive CLIs. An argument like filename is positional and required. An option like --output is named and typically optional.
Typer infers the type from your function signature, but sometimes you need more control. Use typer.Argument() and typer.Option() to customize behavior:
# file_processor.py
import typer
from pathlib import Path
app = typer.Typer()
@app.command()
def process(
input_file: Path = typer.Argument(..., help="File to process"),
output_file: Path = typer.Option(None, help="Output file path"),
verbose: bool = typer.Option(False, "-v", "--verbose", help="Verbose output"),
count: int = typer.Option(1, "-c", "--count", help="Number of iterations")
):
"""Process a file with various options."""
input_text = input_file.read_text()
typer.echo(f"Read {len(input_text)} characters from {input_file}")
if verbose:
typer.echo(f"Verbose: Processing with count={count}")
if output_file:
output_file.write_text(input_text.upper())
typer.echo(f"Wrote output to {output_file}")
if __name__ == "__main__":
app()
Output:
$ python file_processor.py --help
Usage: file_processor.py [OPTIONS] INPUT_FILE
Process a file with various options.
Options:
--output-file PATH Output file path
-v, --verbose Verbose output
-c, --count INTEGER Number of iterations
--help Show this message and exit.
Arguments:
INPUT_FILE File to process [required]
$ echo "hello world" > input.txt
$ python file_processor.py input.txt --output-file output.txt -v --count 2
Read 11 characters from input.txt
Verbose: Processing with count=2
Wrote output to output.txt
The ... (Ellipsis) in typer.Argument(...) indicates a required argument. The typer.Option() call lets you specify default values, short flags (-v), and long flags (--verbose) simultaneously. Typer automatically converts hyphens to underscores in flag names, so --output-file maps to the output_file parameter.
Type Annotations for Validation
One of Typer’s superpowers is automatic validation through type hints. When you declare a parameter as int, Typer ensures the user provides an integer. If they don’t, Typer shows a helpful error message instead of crashing with a cryptic traceback.
# calculator.py
import typer
app = typer.Typer()
@app.command()
def add(a: int, b: int):
"""Add two integers."""
typer.echo(f"{a} + {b} = {a + b}")
@app.command()
def greet(name: str, age: int = 25):
"""Greet someone with their age."""
typer.echo(f"Hello, {name}! You are {age} years old.")
@app.command()
def enable_feature(feature_name: str, enabled: bool = True):
"""Toggle a feature on or off."""
status = "enabled" if enabled else "disabled"
typer.echo(f"Feature '{feature_name}' is {status}")
if __name__ == "__main__":
app()
Output:
$ python calculator.py add 5 3
5 + 3 = 8
$ python calculator.py add five 3
Error: Invalid value for 'A': 'five' is not a valid integer.
$ python calculator.py greet Alice 30
Hello, Alice! You are 30 years old.
$ python calculator.py enable_feature logging --no-enabled
Feature 'logging' is disabled
Notice how passing a string where an integer is expected produces a clear error message. Typer validates at the CLI layer, not in your code. Boolean flags get special treatment: you can pass --enabled/--no-enabled or just toggle the default. This is powerful validation without writing a single if-statement for type checking.
Multiple Commands with app.command()
Professional CLI applications often have subcommands. Git is the classic example: git commit, git push, and git pull are all subcommands. Typer makes this structure effortless. Every function decorated with @app.command() becomes a subcommand automatically.
# database_cli.py
import typer
app = typer.Typer()
@app.command()
def migrate(version: str = typer.Option("latest")):
"""Run database migrations."""
typer.echo(f"Migrating to version: {version}")
@app.command()
def backup(database: str = typer.Argument("main"), output: str = typer.Option("backup.sql")):
"""Create a database backup."""
typer.echo(f"Backing up '{database}' to {output}")
@app.command()
def restore(backup_file: str = typer.Argument(...)):
"""Restore database from backup."""
typer.echo(f"Restoring from {backup_file}")
@app.command()
def status():
"""Show database status."""
typer.echo("Database Status: OK")
typer.echo("Tables: 42")
typer.echo("Size: 2.3 GB")
if __name__ == "__main__":
app()
Output:
$ python database_cli.py --help
Usage: database_cli.py [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
backup Create a database backup.
migrate Run database migrations.
restore Restore database from backup.
status Show database status.
$ python database_cli.py status
Database Status: OK
Tables: 42
Size: 2.3 GB
$ python database_cli.py backup mydb --output backup_2026.sql
Backing up 'mydb' to backup_2026.sql
This structure scales beautifully. As your application grows, you can organize commands in separate modules and import them, keeping the codebase maintainable.

Interactive Prompts and Confirmations
Sometimes you need to ask the user for input during execution, not at the command line. Typer provides interactive prompts for this scenario. Use typer.prompt() for collecting input and typer.confirm() for yes/no questions.
# interactive_app.py
import typer
app = typer.Typer()
@app.command()
def create_user():
"""Interactively create a new user."""
username = typer.prompt("Enter username")
email = typer.prompt("Enter email")
password = typer.prompt("Enter password", hide_input=True)
typer.echo(f"User '{username}' created successfully")
@app.command()
def delete_file(filename: str):
"""Delete a file with confirmation."""
if typer.confirm(f"Delete '{filename}'?"):
typer.echo(f"Deleted {filename}")
else:
typer.echo("Cancelled")
@app.command()
def setup():
"""Run interactive setup wizard."""
project_name = typer.prompt("Project name")
author = typer.prompt("Author name")
use_git = typer.confirm("Initialize Git repository?")
typer.echo(f"Setting up project '{project_name}'...")
if use_git:
typer.echo("Initialized Git repository")
typer.echo(f"Project ready! ({author})")
if __name__ == "__main__":
app()
Output:
$ python interactive_app.py create_user
Enter username: alice
Enter email: alice@example.com
Enter password:
User 'alice' created successfully
$ python interactive_app.py delete_file data.csv
Delete 'data.csv'? [y/N]: y
Deleted data.csv
$ python interactive_app.py setup
Project name: MyApp
Author name: Bob Smith
Initialize Git repository? [y/N]: y
Setting up project 'MyApp'...
Initialized Git repository
Project ready! (Bob Smith)
The hide_input=True parameter masks password input, preventing shoulder surfers from seeing sensitive data. typer.confirm() accepts yes/no responses flexibly, handling “y”, “yes”, “n”, “no” and returning a boolean. This creates seamless user experiences without managing stdin directly.
Rich Output with Colors
Boring terminals are outdated. The Rich library (included with Typer) enables beautiful colored output, tables, and formatted text. This transforms CLIs from utilitarian to delightful.
# styled_output.py
import typer
from rich.console import Console
from rich.table import Table
from rich import print as rprint
app = typer.Typer()
console = Console()
@app.command()
def colors():
"""Display colored text."""
rprint("[bold red]Error:[/bold red] Something went wrong!")
rprint("[green]Success:[/green] Operation completed")
rprint("[cyan]Info:[/cyan] Current status is normal")
@app.command()
def show_status():
"""Display status with a formatted table."""
table = Table(title="System Status")
table.add_column("Component", style="cyan")
table.add_column("Status", style="magenta")
table.add_column("Load", style="green")
table.add_row("CPU", "OK", "45%")
table.add_row("Memory", "OK", "62%")
table.add_row("Disk", "Warning", "88%")
console.print(table)
@app.command()
def progress_demo():
"""Show progress with real-time updates."""
with console.status("[bold green]Processing...") as status:
import time
for i in range(5):
time.sleep(0.2)
status.update(f"[bold green]Processing step {i+1}/5...")
console.print("[bold green]Done!")
if __name__ == "__main__":
app()
Output:
$ python styled_output.py colors
Error: Something went wrong!
Success: Operation completed
Info: Current status is normal
$ python styled_output.py show_status
System Status
Component Status Load
CPU OK 45%
Memory OK 62%
Disk Warning 88%
$ python styled_output.py progress_demo
Processing step 5/5...
Done!
Rich markup is simple: [bold red]text[/bold red] applies bold red styling. You can create tables, progress bars, panels, and more. This visual feedback keeps users informed and engaged, especially important for long-running operations.
Error Handling in CLI Apps
Proper error handling separates production-ready CLIs from toy scripts. Typer provides typer.Exit() to terminate with a specific exit code, and the rich.console.Console class has methods for displaying errors elegantly.
# error_handling.py
import typer
from pathlib import Path
from rich.console import Console
app = typer.Typer()
console = Console()
@app.command()
def read_file(filename: str):
"""Read and display a file."""
try:
file_path = Path(filename)
if not file_path.exists():
console.print(f"[red]Error:[/red] File '{filename}' not found", style="bold")
raise typer.Exit(code=1)
content = file_path.read_text()
console.print(f"[green]Success:[/green] Read {len(content)} characters")
print(content)
except Exception as e:
console.print(f"[red]Error:[/red] {str(e)}", style="bold")
raise typer.Exit(code=2)
@app.command()
def process(input_file: str, output_file: str = "output.txt"):
"""Process a file with error checking."""
input_path = Path(input_file)
output_path = Path(output_file)
if not input_path.exists():
console.print(f"[red]Input Error:[/red] {input_file} not found", style="bold red")
raise typer.Exit(code=1)
if output_path.exists():
if not typer.confirm(f"Overwrite {output_file}?"):
console.print("[yellow]Cancelled[/yellow]")
raise typer.Exit(code=0)
try:
data = input_path.read_text()
output_path.write_text(data.upper())
console.print(f"[green]Success:[/green] Processed {input_file} -> {output_file}")
except IOError as e:
console.print(f"[red]IO Error:[/red] {str(e)}", style="bold red")
raise typer.Exit(code=3)
if __name__ == "__main__":
app()
Output:
$ python error_handling.py read_file missing.txt
Error: File 'missing.txt' not found
$ echo $?
1
$ python error_handling.py read_file data.txt
Success: Read 42 characters
[file contents here]
$ python error_handling.py process input.txt --output-file output.txt
Success: Processed input.txt -> output.txt
Exit codes matter for automation and scripting. Return 0 for success, non-zero for failure. This allows shell scripts and other tools to detect success and fail fast. Always validate user input early and fail fast with clear messages.

Real-Life Example: Smart File Organizer
Now let’s build a complete, production-ready CLI tool: a smart file organizer that sorts files in a directory by their extension. This example combines everything we’ve learned: multiple commands, type validation, interactive prompts, error handling, and rich output.
# file_organizer.py
import typer
from pathlib import Path
from rich.console import Console
from rich.table import Table
from collections import defaultdict
import shutil
app = typer.Typer(help="Smart file organizer with validation and safety checks")
console = Console()
@app.command()
def organize(
directory: Path = typer.Argument(".", help="Directory to organize"),
dry_run: bool = typer.Option(True, help="Preview changes without executing"),
create_folders: bool = typer.Option(True, help="Create extension folders"),
):
"""Organize files in a directory by extension."""
if not directory.exists():
console.print(f"[red]Error:[/red] Directory '{directory}' not found", style="bold")
raise typer.Exit(code=1)
if not directory.is_dir():
console.print(f"[red]Error:[/red] '{directory}' is not a directory", style="bold")
raise typer.Exit(code=1)
# Group files by extension
files_by_ext = defaultdict(list)
for file in directory.iterdir():
if file.is_file():
ext = file.suffix or "[no-extension]"
files_by_ext[ext].append(file)
if not files_by_ext:
console.print("[yellow]No files found to organize[/yellow]")
raise typer.Exit(code=0)
# Display summary
table = Table(title="Files to Organize")
table.add_column("Extension", style="cyan")
table.add_column("Count", style="green")
for ext, files in sorted(files_by_ext.items()):
table.add_row(ext, str(len(files)))
console.print(table)
if dry_run:
console.print("[yellow]Dry-run mode: No changes will be made[/yellow]")
return
if not typer.confirm("Execute organization?"):
console.print("[yellow]Cancelled[/yellow]")
raise typer.Exit(code=0)
# Move files
moved_count = 0
for ext, files in files_by_ext.items():
if create_folders and ext != "[no-extension]":
folder = directory / ext.lstrip(".")
folder.mkdir(exist_ok=True)
for file in files:
try:
shutil.move(str(file), str(folder / file.name))
moved_count += 1
except Exception as e:
console.print(f"[red]Failed to move {file.name}:[/red] {str(e)}")
console.print(f"[green]Success:[/green] Moved {moved_count} files")
@app.command()
def analyze(directory: Path = typer.Argument(".", help="Directory to analyze")):
"""Analyze file distribution in a directory."""
if not directory.exists() or not directory.is_dir():
console.print(f"[red]Error:[/red] Invalid directory '{directory}'", style="bold")
raise typer.Exit(code=1)
total_size = 0
files_by_ext = defaultdict(int)
for file in directory.rglob("*"):
if file.is_file():
ext = file.suffix or "[no-extension]"
files_by_ext[ext] += 1
total_size += file.stat().st_size
table = Table(title=f"Analysis of {directory}")
table.add_column("Extension", style="cyan")
table.add_column("Files", style="green")
for ext in sorted(files_by_ext.keys()):
table.add_row(ext, str(files_by_ext[ext]))
console.print(table)
console.print(f"Total files: {sum(files_by_ext.values())}")
console.print(f"Total size: {total_size / (1024*1024):.2f} MB")
if __name__ == "__main__":
app()
Output:
$ python file_organizer.py organize ./test_dir
Files to Organize
Extension Count
.txt 5
.pdf 3
.jpg 7
.py 2
Dry-run mode: No changes will be made
$ python file_organizer.py organize ./test_dir --no-dry-run
Files to Organize
Extension Count
.txt 5
.pdf 3
.jpg 7
.py 2
Execute organization? [y/N]: y
Success: Moved 17 files
$ python file_organizer.py analyze ./test_dir
Analysis of ./test_dir
Extension Files
.jpg 7
.pdf 3
.py 2
.txt 5
Total files: 17
Total size: 45.32 MB
This file organizer demonstrates production best practices: it validates input, provides dry-run mode for safety, uses tables for clarity, handles errors gracefully, and offers multiple commands for different use cases. You could package this as a standalone tool and distribute it via pip.
Frequently Asked Questions
How do I package a Typer app as a standalone tool?
Use setuptools or Poetry to create a package with an entry point. In your pyproject.toml, add:
[project.scripts]
my-cli = "my_module:app"
Then install with pip install -e .. Your app becomes available as a system command: my-cli --help.
Can Typer generate shell completion scripts?
Yes! Typer apps automatically support bash, zsh, and fish completion through the shellingham library. Users can run python my_cli.py --install-completion to set up completions for their shell.
How should I test Typer applications?
Use the CliRunner from Click (which Typer uses under the hood). Testing example:
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["add", "5", "3"])
assert result.exit_code == 0
assert "8" in result.output
What about complex types like lists or JSON objects?
Use Python’s built-in types directly. Typer handles List[str], List[int], and other generic types intelligently. For JSON, accept a string and parse it with json.loads() in your function.
How do I use environment variables in a Typer app?
Use typer.Option() with the envvar parameter: api_key: str = typer.Option(..., envvar="API_KEY"). Typer will check the environment variable if the CLI argument isn’t provided.
Can I create command groups or nested subcommands?
Yes! Create separate Typer instances and add them as command groups:
db_app = typer.Typer()
@db_app.command()
def migrate(): pass
app = typer.Typer()
app.add_typer(db_app, name="db")
# Usage: python cli.py db migrate
Conclusion
Typer brings modern Python practices to CLI development. By leveraging type hints and sensible defaults, it eliminates boilerplate while maintaining power and flexibility. Whether you’re building internal tools, developer utilities, or the next popular open-source CLI, Typer gives you a solid foundation.
The journey from simple functions to professional CLI applications is smooth with Typer. Start with a basic command, add features incrementally, and scale to complex multi-command applications without refactoring. For deeper learning, explore the official Typer documentation and examine real-world projects using Typer on GitHub.
Your CLI adventure awaits. Happy building!
Related Articles
Deepen your command-line expertise with these related tutorials:
- Building REST APIs with FastAPI — Typer’s spiritual sibling for web applications, using the same elegant design principles
- Python Package Management and Distribution — Learn how to package and publish your CLI tools on PyPI
- Unit Testing and Test-Driven Development in Python — Best practices for testing CLI applications thoroughly