The Three Ways People Build CLI Tools in Python (And Why Most Pick Wrong)
When you first need to turn a Python script into a proper command-line tool, three paths come up repeatedly: sys.argv parsing by hand, the standard library’s argparse, or third-party libraries like Click. I’ve used all three across dozens of internal tools. The choice has a bigger impact than it looks.
The fourth ingredient — making your CLI look good — usually gets skipped entirely. That’s where Rich comes in. Together, Click and Rich handle both behavior and presentation. The gap between a tool your teammates reach for every day and one they quietly replace with a shell alias often comes down to how clearly it reports progress and errors.
Approach Comparison: sys.argv vs argparse vs Click
Raw sys.argv
This is the “just ship it” approach. You grab sys.argv[1:], do some manual parsing, and call it done.
import sys
def main():
if len(sys.argv) < 2:
print("Usage: tool.py <filename>")
sys.exit(1)
filename = sys.argv[1]
process(filename)
Fine for a single positional argument on a throwaway script. Add optional flags, multiple subcommands, or type validation, and it turns painful fast.
argparse (stdlib)
argparse has been the default answer for years. It handles flags, positional args, help text generation, and basic type coercion — no extra install required.
import argparse
parser = argparse.ArgumentParser(description="Process files")
parser.add_argument("filename", help="Input file")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
For small tools, argparse does the job. But once you add multiple subcommands, nested groups, callbacks, or tests, the boilerplate stacks up and the code gets hard to follow.
Click
Click uses Python decorators to define CLI interfaces directly on your functions. The result reads naturally and stays maintainable as the tool grows.
import click
@click.command()
@click.argument("filename")
@click.option("--verbose", is_flag=True, help="Enable verbose output")
def process(filename, verbose):
"""Process a file and report results."""
if verbose:
click.echo(f"Processing: {filename}")
Same functionality, but the intent is immediately clear. Each parameter is documented where it’s declared. Testing is also cleaner — Click ships a built-in test runner called CliRunner.
Pros and Cons Worth Knowing Before You Commit
argparse Pros & Cons
- Pro: Zero dependencies — ships with Python
- Pro: Stable, well-documented, widely understood
- Con: Subcommands require verbose setup with subparsers
- Con: Testing means mocking sys.argv or spinning up a subprocess
- Con: No built-in color or styled output
Click Pros & Cons
- Pro: Decorator-based — the interface lives next to the logic
- Pro: Subcommands are first-class via
@click.group() - Pro: Built-in test utilities (
CliRunner) - Pro: Composable — commands can be assembled from separate modules
- Con: External dependency (though tiny and stable)
- Con: Some argument-parsing edge cases differ from argparse conventions
Rich Pros & Cons
- Pro: Tables, progress bars, syntax highlighting, panels — all built in
- Pro: Automatic terminal detection (no color codes bleed into piped output)
- Pro: Pairs cleanly with Click — no conflicts
- Con: Adds ~2MB to your dependency footprint
- Con: Overkill for a single-purpose script you’ll run once and forget
My recommendation: use argparse for small internal scripts that one person runs occasionally. Use Click + Rich for anything shared with a team or packaged for distribution. Tools like httpie, black, and poetry are all built on Click — not coincidentally, they’re also tools people genuinely enjoy using. Clear progress output and readable error messages are what separate a tool people trust from one they work around.
Recommended Setup
Install Dependencies
pip install click rich
Project Structure
Keep the CLI layer thin. Business logic belongs in modules, not inside Click decorators.
mytool/
├── cli.py ← Click commands
├── core.py ← Actual logic
├── __init__.py
pyproject.toml ← Entry point config
pyproject.toml Entry Point
[project.scripts]
mytool = "mytool.cli:main"
After pip install -e ., users run mytool directly from any directory — no python -m prefix required.
Implementation Guide: Building a Real CLI Tool
Step 1 — Basic Command Group
Start with a group to support multiple subcommands from day one. Retrofitting one later — after commands have shipped — is more painful than it sounds.
import click
from rich.console import Console
console = Console()
@click.group()
@click.version_option(version="1.0.0")
def main():
"""MyTool — file processing utility."""
pass
@main.command()
@click.argument("filepath", type=click.Path(exists=True))
@click.option("--format", type=click.Choice(["json", "csv", "table"]), default="table")
@click.option("--verbose", "-v", is_flag=True)
def analyze(filepath, format, verbose):
"""Analyze a file and display results."""
if verbose:
console.print(f"[dim]Reading: {filepath}[/dim]")
# ... call core logic here
Step 2 — Rich Tables for Structured Output
Plain print() works, but a table makes results instantly scannable. Rich tables support colors, alignment, and row highlighting — zero boilerplate.
from rich.table import Table
def display_results(records):
table = Table(title="Analysis Results", show_lines=True)
table.add_column("File", style="cyan", no_wrap=True)
table.add_column("Size", justify="right")
table.add_column("Status", justify="center")
for r in records:
status_color = "green" if r["ok"] else "red"
table.add_row(
r["name"],
f"{r['size']:,} bytes",
f"[{status_color}]{r['status']}[/{status_color}]"
)
console.print(table)
Step 3 — Progress Bars for Long Operations
Anything that takes more than half a second needs visible feedback. Rich’s progress bar integrates cleanly with loops and handles multiple simultaneous tasks without extra threading work.
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
def process_files(files):
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("{task.completed}/{task.total}"),
) as progress:
task = progress.add_task("Processing files...", total=len(files))
for f in files:
process_single(f)
progress.advance(task)
Step 4 — Consistent Error Handling
Raising ClickException prints a formatted error message and exits with a non-zero status code. Pair it with Rich’s markup so the error stands out visually.
@main.command()
@click.argument("config", type=click.Path())
def run(config):
"""Run with a config file."""
try:
settings = load_config(config)
except FileNotFoundError:
console.print(f"[bold red]Error:[/bold red] Config file not found: {config}")
raise click.Abort()
except ValueError as e:
raise click.ClickException(str(e))
Step 5 — Testing with CliRunner
Click’s built-in test runner lets you invoke commands programmatically. No subprocess overhead, no mocking sys.argv — just call invoke() and inspect the result. Tests run fast and stay isolated.
from click.testing import CliRunner
from mytool.cli import main
def test_analyze_command():
runner = CliRunner()
with runner.isolated_filesystem():
with open("sample.txt", "w") as f:
f.write("test content")
result = runner.invoke(main, ["analyze", "sample.txt"])
assert result.exit_code == 0
assert "Results" in result.output
Small Patterns That Make a Big Difference
- Use
click.Path(exists=True)instead of a raw string argument — Click validates the path before your code runs and shows a clean error automatically. - Add
--dry-runto destructive commands as a flag from day one. Users trust tools more when they can preview what will happen before committing. - Use
console.print()for display,click.echo()for machine-readable output that might be piped. Rich’sConsolecorrectly separatesstderrfromstdout. - Keep
--helptext short and verb-focused. “Analyze a file” beats “This command is used for analyzing files.” Click shows the docstring directly — write it for users, not documentation generators.
When Not to Use Click
If your script runs once inside a CI pipeline and output always gets piped to another tool, Click’s interactivity adds nothing. A straightforward argparse script with --output json is the right call there. Click earns its place when a human is on the other end of the terminal.
Need a full-screen TUI with keyboard navigation? Look at Textual — built by the same team as Rich, designed specifically for that use case.

