Python CLI Tools: argparse vs Click vs Typer
Compare Python's top CLI frameworks. Build command-line tools with argparse, Click, and Typer, with examples for arguments, options, subcommands, and validation.
What you'll learn
- ✓Build CLIs with argparse, Click, and Typer
- ✓Add arguments, options, flags, and subcommands
- ✓Validate and transform input
- ✓Choose the right framework for your project
Prerequisites
- •Python functions and decorators
- •Basic command-line usage
Why Build CLI Tools?
Command-line interfaces are the backbone of developer tooling. Scripts that accept arguments, display help text, and validate input are far more useful than scripts with hardcoded values. Python offers three main options for building CLIs:
- argparse — Built into the standard library, no dependencies
- Click — Third-party, decorator-based, widely used in production
- Typer — Built on Click, uses type hints for automatic CLI generation
This guide builds the same tool with each framework so you can compare them directly.
argparse: The Standard Library Option
argparse ships with Python and requires no installation. It is verbose but powerful.
Basic Arguments and Options
import argparse
def main():
parser = argparse.ArgumentParser(
description="Process and analyze text files."
)
# Positional argument (required)
parser.add_argument(
'filename',
help='Path to the input file'
)
# Optional argument with a value
parser.add_argument(
'-o', '--output',
default='result.txt',
help='Output file path (default: result.txt)'
)
# Flag (boolean)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Enable verbose output'
)
# Numeric option with type validation
parser.add_argument(
'-n', '--lines',
type=int,
default=10,
help='Number of lines to process (default: 10)'
)
# Choice from a fixed set
parser.add_argument(
'--format',
choices=['json', 'csv', 'text'],
default='text',
help='Output format'
)
args = parser.parse_args()
if args.verbose:
print(f"Processing {args.filename}")
print(f"Output: {args.output}")
print(f"Lines: {args.lines}")
print(f"Format: {args.format}")
# Your logic here
print(f"Would process {args.filename} -> {args.output}")
if __name__ == '__main__':
main()
Running python script.py --help produces formatted help text automatically.
Subcommands
For tools with multiple operations (like git commit, git push):
import argparse
def handle_init(args):
print(f"Initializing project: {args.name}")
if args.template:
print(f"Using template: {args.template}")
def handle_build(args):
print(f"Building project (mode: {args.mode})")
if args.clean:
print("Cleaning build artifacts first")
def handle_deploy(args):
print(f"Deploying to {args.target}")
def main():
parser = argparse.ArgumentParser(description="Project management tool")
subparsers = parser.add_subparsers(dest='command', help='Available commands')
subparsers.required = True
# init subcommand
init_parser = subparsers.add_parser('init', help='Initialize a new project')
init_parser.add_argument('name', help='Project name')
init_parser.add_argument('--template', help='Project template to use')
init_parser.set_defaults(func=handle_init)
# build subcommand
build_parser = subparsers.add_parser('build', help='Build the project')
build_parser.add_argument(
'--mode',
choices=['debug', 'release'],
default='debug'
)
build_parser.add_argument('--clean', action='store_true')
build_parser.set_defaults(func=handle_build)
# deploy subcommand
deploy_parser = subparsers.add_parser('deploy', help='Deploy the project')
deploy_parser.add_argument(
'target',
choices=['staging', 'production']
)
deploy_parser.set_defaults(func=handle_deploy)
args = parser.parse_args()
args.func(args)
if __name__ == '__main__':
main()
Custom Validation
import argparse
import pathlib
def valid_file(path_str):
"""Custom type that validates the file exists."""
path = pathlib.Path(path_str)
if not path.exists():
raise argparse.ArgumentTypeError(f"File not found: {path}")
if not path.is_file():
raise argparse.ArgumentTypeError(f"Not a file: {path}")
return path
def port_number(value):
"""Custom type that validates port range."""
port = int(value)
if not (1 <= port <= 65535):
raise argparse.ArgumentTypeError(f"Port must be 1-65535, got {port}")
return port
parser = argparse.ArgumentParser()
parser.add_argument('input', type=valid_file, help='Input file')
parser.add_argument('--port', type=port_number, default=8080)
Click: The Decorator-Based Framework
Click uses decorators to define commands, which leads to cleaner, more readable code. Install it with pip install click.
Basic Command
import click
@click.command()
@click.argument('filename')
@click.option('-o', '--output', default='result.txt', help='Output file path')
@click.option('-v', '--verbose', is_flag=True, help='Enable verbose output')
@click.option('-n', '--lines', default=10, type=int, help='Number of lines')
@click.option(
'--format', 'output_format', # Rename to avoid shadowing built-in
type=click.Choice(['json', 'csv', 'text']),
default='text',
help='Output format'
)
def process(filename, output, verbose, lines, output_format):
"""Process and analyze text files."""
if verbose:
click.echo(f"Processing {filename}")
click.echo(f"Output: {output}")
click.echo(f"Would process {filename} -> {output} ({output_format})")
if __name__ == '__main__':
process()
Click Groups (Subcommands)
import click
@click.group()
@click.option('--debug/--no-debug', default=False)
@click.pass_context
def cli(ctx, debug):
"""Project management tool."""
ctx.ensure_object(dict)
ctx.obj['DEBUG'] = debug
@cli.command()
@click.argument('name')
@click.option('--template', help='Project template')
@click.pass_context
def init(ctx, name, template):
"""Initialize a new project."""
if ctx.obj['DEBUG']:
click.echo("Debug mode is on")
click.echo(f"Initializing project: {name}")
if template:
click.echo(f"Using template: {template}")
@cli.command()
@click.option('--mode', type=click.Choice(['debug', 'release']), default='debug')
@click.option('--clean', is_flag=True)
def build(mode, clean):
"""Build the project."""
if clean:
click.echo("Cleaning build artifacts")
click.echo(f"Building in {mode} mode")
@cli.command()
@click.argument('target', type=click.Choice(['staging', 'production']))
@click.confirmation_option(prompt='Are you sure you want to deploy?')
def deploy(target):
"""Deploy the project to a target environment."""
click.echo(f"Deploying to {target}")
if __name__ == '__main__':
cli()
Click Features
Click provides many built-in features that argparse requires manual implementation for:
import click
@click.command()
@click.option('--name', prompt='Your name', help='Your name')
@click.option(
'--password',
prompt=True,
hide_input=True, # Like getpass
confirmation_prompt=True # Ask twice
)
@click.option(
'--count',
default=1,
show_default=True, # Shows "(default: 1)" in help
type=click.IntRange(1, 100) # Built-in range validation
)
@click.option(
'--color',
type=click.Choice(['red', 'green', 'blue'], case_sensitive=False)
)
def setup(name, password, count, color):
"""Interactive setup wizard."""
click.echo(f"Setting up for {name}")
click.echo(f"Count: {count}")
# Styled output
click.secho("Success!", fg='green', bold=True)
click.secho("Warning: check config", fg='yellow')
click.secho("Error: something failed", fg='red', err=True)
# Progress bar
import time
items = range(100)
with click.progressbar(items, label='Processing') as bar:
for item in bar:
time.sleep(0.01)
if __name__ == '__main__':
setup()
File Handling in Click
import click
@click.command()
@click.argument('input', type=click.File('r'))
@click.argument('output', type=click.File('w'))
def convert(input, output):
"""Convert input file to uppercase and write to output."""
for line in input:
output.write(line.upper())
click.echo("Conversion complete!")
# Usage: python script.py input.txt output.txt
# Also supports: python script.py - - (for stdin/stdout)
if __name__ == '__main__':
convert()
Typer: Type Hints as CLI Definition
Typer builds on Click but uses Python type hints to define your CLI automatically. Install with pip install typer.
Basic Command
import typer
from pathlib import Path
from typing import Annotated
app = typer.Typer(help="Process and analyze text files.")
@app.command()
def process(
filename: str,
output: Annotated[str, typer.Option(help="Output file path")] = "result.txt",
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
lines: Annotated[int, typer.Option(help="Number of lines")] = 10,
output_format: Annotated[
str, typer.Option("--format", help="Output format")
] = "text",
):
"""Process a text file and generate output."""
if verbose:
typer.echo(f"Processing {filename}")
typer.echo(f"Would process {filename} -> {output}")
if __name__ == '__main__':
app()
The type hints (str, int, bool) automatically determine how arguments are parsed. A bool parameter becomes a flag, an int gets numeric validation, and so on.
Typer Subcommands
import typer
from typing import Annotated
from enum import Enum
app = typer.Typer(help="Project management tool.")
class BuildMode(str, Enum):
debug = "debug"
release = "release"
class DeployTarget(str, Enum):
staging = "staging"
production = "production"
@app.command()
def init(
name: str,
template: Annotated[str | None, typer.Option(help="Project template")] = None,
):
"""Initialize a new project."""
typer.echo(f"Initializing project: {name}")
if template:
typer.echo(f"Using template: {template}")
@app.command()
def build(
mode: Annotated[BuildMode, typer.Option()] = BuildMode.debug,
clean: Annotated[bool, typer.Option()] = False,
):
"""Build the project."""
if clean:
typer.echo("Cleaning build artifacts")
typer.echo(f"Building in {mode.value} mode")
@app.command()
def deploy(
target: DeployTarget,
):
"""Deploy the project."""
if target == DeployTarget.production:
confirm = typer.confirm("Deploy to production?")
if not confirm:
raise typer.Abort()
typer.echo(f"Deploying to {target.value}")
if __name__ == '__main__':
app()
Typer Advanced Features
import typer
from typing import Annotated
from pathlib import Path
app = typer.Typer()
@app.command()
def analyze(
# Path validation
input_file: Annotated[
Path,
typer.Argument(
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
help="File to analyze"
)
],
# Multiple values
tags: Annotated[
list[str] | None,
typer.Option(help="Tags to filter by")
] = None,
# Numeric range
threshold: Annotated[
float,
typer.Option(min=0.0, max=1.0, help="Score threshold")
] = 0.5,
):
"""Analyze a file with optional filtering."""
typer.echo(f"Analyzing: {input_file}")
typer.echo(f"Threshold: {threshold}")
if tags:
typer.echo(f"Tags: {', '.join(tags)}")
# Rich output with colors
typer.secho("Analysis complete!", fg=typer.colors.GREEN, bold=True)
# Progress
import time
with typer.progressbar(range(100), label="Processing") as progress:
for _ in progress:
time.sleep(0.01)
if __name__ == '__main__':
app()
Side-by-Side Comparison
Here is the same simple tool in all three frameworks:
# === argparse ===
import argparse
parser = argparse.ArgumentParser(description="Greet someone")
parser.add_argument('name', help='Name to greet')
parser.add_argument('--greeting', default='Hello', help='Greeting to use')
parser.add_argument('--shout', action='store_true', help='SHOUT the greeting')
args = parser.parse_args()
message = f"{args.greeting}, {args.name}!"
print(message.upper() if args.shout else message)
# === Click ===
import click
@click.command()
@click.argument('name')
@click.option('--greeting', default='Hello', help='Greeting to use')
@click.option('--shout', is_flag=True, help='SHOUT the greeting')
def greet(name, greeting, shout):
"""Greet someone."""
message = f"{greeting}, {name}!"
click.echo(message.upper() if shout else message)
if __name__ == '__main__':
greet()
# === Typer ===
import typer
from typing import Annotated
def greet(
name: str,
greeting: Annotated[str, typer.Option(help="Greeting to use")] = "Hello",
shout: Annotated[bool, typer.Option(help="SHOUT the greeting")] = False,
):
"""Greet someone."""
message = f"{greeting}, {name}!"
typer.echo(message.upper() if shout else message)
if __name__ == '__main__':
typer.run(greet)
When to Use Each Framework
| Feature | argparse | Click | Typer |
|---|---|---|---|
| Install required | No | Yes | Yes |
| Code style | Imperative | Decorators | Type hints |
| Learning curve | Medium | Low | Lowest |
| Subcommands | Verbose | Clean | Clean |
| Testing | Manual | Built-in CliRunner | Built-in CliRunner |
| Prompts/colors | Manual | Built-in | Built-in |
| Shell completion | No | Plugin | Built-in |
Choose argparse when you cannot add dependencies or need fine-grained control over parsing behavior.
Choose Click when you want a battle-tested framework with excellent documentation and a large ecosystem of plugins.
Choose Typer when you want the fastest path from function signature to working CLI. It is the most Pythonic option if you already use type hints throughout your codebase.
Wrapping Up
All three frameworks produce professional CLIs with help text, validation, and subcommands. The main difference is how you define the interface: argparse uses an imperative builder pattern, Click uses decorators, and Typer uses type annotations. For new projects, Typer offers the best developer experience with the least boilerplate. For projects that cannot take on dependencies, argparse remains a solid choice. Pick the one that fits your team’s style and dependency policy, and focus on building the actual tool logic.
Related articles
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.
- Python Python Environments: venv, pyenv, and Poetry Guide
Master Python environment management with venv for virtual environments, pyenv for version switching, and Poetry for dependency management.
- Python Python Match Statement: Structural Pattern Matching
Master Python's match statement with structural pattern matching. Learn literal, sequence, mapping, class, guard, and OR patterns with practical examples.
- Python Pydantic v2: Data Validation and Settings Management
Learn Pydantic v2 for data validation, serialization, and settings management in Python. Covers models, validators, computed fields, and BaseSettings.